commit 263718969027809bc7fdf7dd38e85f6bf4ac387c Author: Daniel Date: Tue Sep 8 13:31:57 2026 -0700 init diff --git a/art-direction.md b/art-direction.md new file mode 100644 index 0000000..52117a5 --- /dev/null +++ b/art-direction.md @@ -0,0 +1,760 @@ +# Sanctification TCG - Art Direction + +## Goal + +I want the cards and the product as a whole to have a very distinct visual identity. + +The current direction I like most is: + +> **Stained-glass-inspired as the main style, with tapestry and illuminated manuscript influence as supporting texture and ornament.** + +The overall goal should be: + +> **Sacred, beautiful, and stylized. Not a dense museum reconstruction.** + +I do not want the cards to feel like a generic fantasy TCG, casino game, anime card game, or hyper-realistic historical reenactment. + +The art should feel reverent and Christian first, while still being visually strong enough that collecting the cards feels exciting. + +--- + +## Core Visual Principles + +Across the whole product, the artwork should generally follow these ideas: + +- Sacred and reverent tone. +- Stained-glass-inspired composition. +- Jewel-tone palette where appropriate. +- Gold can be used as an accent, but should become more prominent as rarity increases. +- Tapestry, linen, parchment, and illuminated-manuscript textures can support the overall look. +- Clear subject silhouettes. +- Cards should remain readable at thumbnail size. +- Avoid hyper-photorealism. +- Avoid anime or comic-book energy. +- Avoid a dark fantasy aesthetic. +- Avoid visual language that feels like gambling, loot boxes, or a casino. +- Avoid overly dense historical reconstruction. +- Prefer symbolic clarity over excessive detail. +- Every card should feel like it belongs to the same product, even when different sets or traditions have their own visual flavor. + +A good way to think about the stained-glass direction is that the cards should feel inspired by sacred windows and devotional art without needing to literally reproduce any specific historical style. + +--- + +## Product-Wide Visual Language + +The card art should influence the rest of the app. + +Possible shared UI motifs: + +- Cathedral-window or arched shapes. +- Restrained ornamental dividers. +- Deep blue, burgundy, cream, green, and gold accents. +- Parchment, stone, linen, or subtle glass-inspired surfaces. +- Illuminated-manuscript-style decorative elements used sparingly. +- Soft lighting rather than neon effects. +- Ornament should support hierarchy rather than fill every empty space. + +The application UI should still feel modern and usable. + +The goal is not to make the entire interface look medieval. + +The historical / sacred art direction should mostly come through: + +- Cards. +- Pack openings. +- Binder / collection presentation. +- Headers and section treatments. +- Decorative accents. +- Important moments and milestones. + +Normal application controls should remain clear and familiar. + +--- + +# Card Art + +## General Composition + +Cards should generally prioritize: + +1. Subject. +2. Identity. +3. Short supporting content. +4. Ornamentation. + +The art should be readable even when the card is relatively small. + +The card should not require the user to zoom in just to understand who or what it represents. + +For character cards, prefer: + +- One clear subject. +- Strong silhouette. +- Limited background complexity. +- Symbolic items associated with the subject. +- Calm, iconic poses over excessive action scenes unless the card itself represents an event. + +For event cards, larger compositions and multiple figures can be used when necessary. + +For locations, items, doctrines, councils, and other categories, the framing can change while still staying within the same overall stained-glass visual language. + +--- + +## Text on the Card + +The physical card should stay concise. + +The app itself can provide the deeper educational content when the card is inspected. + +A normal card can contain: + +- Name. +- Short subtitle or descriptor. +- Short verse, quote, or historical line. +- Reference. + +Example: + +> **David** +> Shepherd King +> +> “The LORD is my shepherd...” +> Psalm 23:1 + +The front content priority should generally be: + +1. Scripture directly related to the card. +2. Historically significant Christian quotation. +3. Concise historical or theological statement. + +We should not force a full verse onto the card when it harms the visual design. + +A short excerpt with an ellipsis is fine when it is clearly intentional. + +The full content can appear in the inspection panel. + +--- + +## Card Back + +The card back should generally be standardized so it does not reveal the identity of a card during pack opening. + +The back should not leak: + +- Card identity. +- Exact rarity. +- Printing. +- Finish. +- Material. +- Other traits that would spoil the reveal. + +It can use the Sanctification branding and common visual language. + +Different sets could potentially have different backs in the future, but only if this does not meaningfully spoil the card reveal. + +--- + +# Card Inspection + +The card itself should remain relatively clean. + +More detailed information belongs in the inspection experience. + +When inspecting a card, the user should be able to: + +- Rotate the card freely. +- Flip between front and back. +- Zoom in to inspect artwork and physical characteristics. +- Observe material properties responding dynamically to lighting. +- Observe wear and imperfections where applicable. +- Return quickly to the originating collection / binder context. + +The inspection UI should also expose the educational and collectible information that does not need to live directly on the physical card. + +Possible sections: + +## About + +- Full verse or quote. +- Description. +- Historical context. +- Theological context. +- Related Scripture. +- Related events / people / cards. +- Learn-more sources. + +## Card + +- Rarity. +- Set. +- Printing. +- Finish. +- Material. +- Wear classification. +- Exact wear percentage. + +## Provenance + +- Original owner. +- Date opened. +- Population. +- Serial / instance information. + +--- + +# Rarity Art Direction + +Rarity should have a major impact on the visual grammar of the card. + +A common card should not look almost as impressive as a legendary card. + +Rarity should primarily affect: + +- Frame complexity. +- Number of ornamental elements. +- Visual storytelling. +- Palette richness. +- Composition complexity. +- Symbolic detail. +- Amount of gold. +- Background complexity. + +Rarity should **not** simply mean adding more glow or visual effects. + +A useful principle: + +> **Higher rarity should add storytelling and ornamentation, not merely more effects.** + +Another way to think about it: + +> **Common cards should feel like a simple parish stained-glass window. Legendary cards should feel closer to the centerpiece window behind the altar.** + +--- + +## Common + +Common cards should be intentionally restrained. + +### Border + +- Thin and simple. +- Geometric. +- Very little decorative ornament. +- No elaborate corner medallions. +- No complex illuminated-manuscript scrollwork. +- No gold. + +### Color + +- Limited palette. +- Roughly 2-3 dominant colors plus cream / parchment. +- More muted or restrained jewel tones. +- Gold used mostly for separation or small accents. + +### Artwork + +- It doesn't need to be just the subject but rather them in the background doing something +- Simple background. +- Larger stained-glass panes. +- Fewer small glass segments. +- Minimal symbolic elements. +- Calm composition. +- No unnecessary visual drama. +- Minimal micro-detail. + +### Overall Feel + +- Humble. +- Clean. +- Grounded. +- Accessible. +- Still beautiful, but obviously not premium. + +Example: + +**Timothy** + +- Timothy holding a scroll. +- Simple ancient street or landscape. +- Simple arch. +- Blue / green / cream palette. +- Thin brass or gold divider. +- No ornate botanical frame. +- No special corner symbols. +- No heavily gilded title plaque. + +![alt text](image-2.png) + +--- + +## Uncommon + +Uncommon cards can begin introducing more refinement. + +### Border + +- Slightly thicker frame. +- Small decorative accents. +- Limited corner ornament. +- Some leaf or manuscript-inspired motifs. + +### Color + +- Slightly broader palette. +- More saturated jewel tones. +- Small use of gold. + +### Artwork + +- More detailed backgrounds. +- Additional symbolic element. +- Slightly more complex stained-glass segmentation. +- More deliberate framing around the subject. + +### Overall Feel + +- Refined. +- Noticeably more interesting than Common. +- Still not highly ornate. + +--- + +## Rare + +Rare cards should feel distinctive. + +### Border + +- Decorative frame. +- More custom ornament. +- Symbolic motifs related to the subject. + +### Color + +- Richer jewel-tone palette. +- More contrast. +- More use of gold. + +### Artwork + +- Stronger visual storytelling. +- More complex stained-glass structures. +- Multiple meaningful background elements. +- More deliberate lighting composition. + +### Overall Feel + +- Distinctive. +- Collectible. +- Clearly above the normal card experience. + +--- + +## Extraordinary + +Extraordinary cards should feel prestigious. + +### Border + +- Elaborate framing. +- Strong illuminated-manuscript influence. +- More bespoke ornamentation. +- Larger use of gold. + +### Color + +- Deep and saturated jewel tones. +- Broad palette while remaining cohesive. +- Strong visual contrast. + +### Artwork + +- Complex composition. +- Multiple symbolic elements. +- Richer environments. +- More detailed stained-glass segmentation. +- Greater sense of scale or drama. + +### Overall Feel + +- Prestigious. +- Elaborate. +- Immediately special. + +--- + +## Legendary + +Legendary cards should be visually unmistakable. + +They can break some of the normal card-layout rules while still clearly belonging to the same product. + +### Border + +- Highly bespoke. +- Custom symbolic elements related specifically to the card. +- Extensive but tasteful ornamentation. +- Rich gilding. + +### Color + +- Full jewel-tone palette where appropriate. +- Strong gold accents. +- Deep color contrast. + +### Artwork + +- Strong visual storytelling. +- Multiple symbols connected directly to the subject. +- Complex stained-glass composition. +- More dramatic light. +- More detailed environments. +- Unique framing or iconography. + +### Overall Feel + +- Immediately special. +- Reverent. +- Iconic. +- Worth stopping to inspect. + +Example: + +**David** + +Possible visual elements: + +- Crown. +- Harp. +- Shepherd staff. +- Sheep. +- Jerusalem. +- Star / celestial imagery. +- Bespoke gold framing. +- Rich red and blue garments. +- Complex stained-glass composition. + +These elements help tell David's story rather than simply adding decoration for decoration's sake. + +![alt text](image-3.png) + + +#### Alt Style - Tapestry + +![alt text](image-4.png) + +--- + +# Rarity vs. Finish + +Rarity and finish are separate systems. + +This is important. + +A Common holographic card should still visually read as a Common card. + +A Legendary plain-paper card should still visually read as Legendary. + +## Rarity controls + +- Graphic design. +- Frame. +- Composition. +- Symbolic storytelling. +- Base art complexity. +- Ornamentation. + +## Finish controls + +- Physical rendering behavior. +- Reflectivity. +- Foil effects. +- Holographic behavior. +- Surface texture. +- Material response to lighting. + +For example: + +- Common Paper Timothy. +- Common Foil Timothy. +- Common Holographic Timothy. +- Common Metal Timothy. + +All of them should still use the same underlying Common Timothy card design. + +--- + +# Material and Finish Direction + +If a collectible property exists, the player should ideally be able to perceive it visually or interactively. + +Examples: + +- Finish -> visible. +- Material -> visible. +- Wear -> visible. +- Imperfections -> visible. +- Printing -> visible. +- Weight -> indirectly observable. +- Provenance -> inspectable metadata. +- Population -> inspectable metadata. + +--- + +## Paper + +- Matte or slightly textured. +- Soft reflection. +- Visible paper grain if inspected closely. + +## Plastic + +- Smoother surface. +- Slightly stronger specular response. +- Cleaner edges. + +## Linen + +- Fine woven normal map. +- Soft diffuse reflection. +- Particularly compatible with tapestry-inspired artwork. + +## Metal + +- Strong metallic response. +- Sharper reflections. +- Visually heavier. +- Should still preserve artwork readability. + +## Foil + +- Angle-dependent reflection. +- Light sweeps visibly across the card. + +## Holographic + +- View-dependent spectral response. +- Holographic effect should ideally use masks so only specific portions of the artwork react. +- Should remain tasteful and avoid a neon / fantasy appearance. + +--- + +# Wear + +Wear should not make cards ugly to the point that the artwork becomes undesirable. + +Even the lowest wear levels should remain visually readable and collectible. + +A 0.00 wear card can be noticeably scratched, worn, or damaged while still looking like a cared-for physical collectible rather than a destroyed object. + +Possible classifications are still subject to change, but could look something like: + +- Mint. +- Near Mint. +- Minimal Wear. +- Moderately Played. +- Worn. + +Wear can affect: + +- Edge whitening. +- Surface scratches. +- Small scuffs. +- Minor print abrasion. +- Subtle corner wear. + +It should not normally obscure: + +- Subject identity. +- Important text. +- Major portions of the artwork. + +Wear patterns should ideally be seeded per card instance so two cards with the same wear value do not necessarily have identical damage. + +--- + +# Printing Variants + +Printing should affect the graphic presentation of the same card identity. + +## Normal + +- Standard frame. +- Name. +- Subtitle. +- Short verse / quote / fact. +- Normal artwork area. + +## Borderless + +- Artwork extends farther toward the card edges. +- Reduced frame. +- Minimal text treatment. + +## Textless + +- Artwork-focused. +- Little or no text. +- Educational information remains available in the inspection panel. + +## Boundless + +- Full-bleed or near-full-bleed artwork. +- Minimal or absent conventional frame. +- Intended to feel like a special printing rather than a higher rarity. + +Printing type should not change the underlying rarity of the card. + +--- + +# Thumbnail Readability + +All normal card designs should remain recognizable in a binder or collection grid. + +At thumbnail size, the user should ideally still be able to identify: + +- The subject or general composition. +- The card rarity through its frame language. +- Major color identity. +- Whether the card has an unusual printing or finish when practical. + +Fine text does not need to remain readable at thumbnail size. + +The card name should become readable as the card grows larger or when selected. + +--- + +# Pack Opening Art Direction + +Pack opening should create anticipation without feeling like a casino. + +Rarity can affect reveal pacing and presentation. + +Possible progression: + +## Common + +- Simple lift. +- Quick flip. +- Minimal lighting change. + +## Uncommon + +- Slight pause. +- Mild environmental emphasis. + +## Rare + +- More deliberate reveal. +- Stronger lighting transition. +- Small audio / visual flourish. + +## Extraordinary + +- More dramatic reveal timing. +- Larger environmental shift. +- More pronounced presentation. + +## Legendary + +- Unique or near-unique reveal treatment. +- Strong visual emphasis. +- Longer anticipation, but still skippable. + +The goal is: + +> **Rarity can affect anticipation without going full casino.** + +All reveal animations should remain skippable. + +--- + +# Things to Avoid + +Across all rarities: + +- Hyper-photorealistic character portraits. +- Anime styling. +- Comic-book styling. +- Neon fantasy effects. +- Generic fantasy RPG framing. +- Excessive glowing particles. +- Overly busy ornament. +- Illegible typography. +- Decorative detail that makes the subject harder to identify. +- Making every card look premium. +- Using rarity only as a glow / particle intensity slider. +- Turning historical Christian art into parody. +- Making Common cards deliberately ugly. + +The distinction should be: + +> **Common is simple. Legendary is elaborate.** + +Not: + +> **Common is bad. Legendary is good.** + +--- + +# MVP Art Goals + +For the MVP, I do not need every card to be a full-scale art project. + +The interesting part is proving that the entire visual system works. + +The MVP should establish: + +- A consistent stained-glass-inspired card style. +- A clearly readable rarity system. +- A reusable card layout system. +- At least one working example of each rarity. +- Multiple materials. +- Multiple finishes. +- Multiple printing types. +- Wear. +- 3D card inspection. +- Light-reactive materials. +- Pack-opening presentation. +- Thumbnail / binder representations. + +I think it makes more sense to create a smaller number of highly intentional art examples and then build the other cards from the same visual system rather than independently designing all 50 cards. + +--- + +# Open Art Questions + +Things that still need to be explored: + +- Exact card dimensions / aspect ratio. +- Final typography. +- Exact color palette. +- Whether each rarity has a fixed base frame or a family of frames. +- Whether card categories have their own secondary visual motifs. +- Whether tradition-specific cards alter UI or border accents. +- Exact visual treatment for controversies. +- Whether halos should be used based on historical iconography, card category, tradition, or purely artistic composition. +- How much motion should exist inside holographic cards. +- Exact distinction between Extraordinary and Legendary. +- Whether higher-rarity cards are always visually more complex or occasionally gain prestige through extreme simplicity. +- How much the card artwork itself changes across printing variants. +- Final card back design. + +--- + +# Current Direction Summary + +The current visual north star is: + +> **Stained glass first. Tapestry and illuminated manuscript second. Sacred and stylized throughout.** + +Rarity should be obvious through design language before the user reads a label. + +Common cards should be restrained and humble. + +Legendary cards should feel bespoke and narratively rich. + +Finishes and materials should enhance the physical card without replacing rarity as the primary graphic hierarchy. + +The cards should be beautiful enough to want to collect, but the overall product should always remain reverent rather than flashy. diff --git a/functional-spec-v1.md b/functional-spec-v1.md new file mode 100644 index 0000000..b12733f --- /dev/null +++ b/functional-spec-v1.md @@ -0,0 +1,358 @@ +# Christian Habit Tracker TCG - Functional Spec + +I think a good name for the application would be Sanctification TSG; as the objective is to grow in Christ and move towards holiness. + +Scenario: Habit trackers are super common place and incredibly overdone. I find the same issue with every single one of them - tracking your habits needs to be a habit itself. Many applications try to add a "carrot" but nothing actually seems to provide a good sense to really reel you back in. +On the opposite front, bad habits don't really have a negative attached to them. + +One thing that has become hugely popular is TCGs - more importantly card openings. This idea that you can collect the cards and try to obtain rare instances or wide variations of said cards. + +All these card games are typically built in their own universe - but I want to do it christian themed. + +Below breaks this down into the large functional pieces of the project - and the unknowns that may need to be explored + + +## Christianity + +At the core foundation, we believe to be Christianity is founded in the words and logic of the Nicene Creed, as follows: + +"We believe in one God the Father Almighty, Maker of heaven and earth, and of all things visible and invisible. + +“And in one Lord Jesus Christ, the only-begotten Son of God, begotten of the Father before all worlds, God of God, Light of Light, Very God of Very God, begotten, not made, being of one substance with the Father by whom all things were made; who for us men, and for our salvation, came down from heaven, and was incarnate by the Holy Spirit of the Virgin Mary, and was made man, and was crucified also for us under Pontius Pilate. He suffered and was buried, and the third day he rose again according to the Scriptures, and ascended into heaven, and sitteth on the right hand of the Father. And he shall come again with glory to judge both the quick and the dead, whose kingdom shall have no end. + +“And we believe in the Holy Spirit, the Lord and Giver of Life, who proceedeth from the Father, who with the Father and the Son together is worshiped and glorified, who spoke by the prophets. And we believe in one holy catholic and apostolic Church. We acknowledge one baptism for the remission of sins. And we look for the resurrection of the dead, and the life of the world to come. Amen" + +## Functional Break Down + +### TCG + +The whole premise of the "game" is just collection. This would become the carrot of your habits and hope to build something a bit more fun and community into the service. + +Daily login would bring in daily packs and each task can also give the daily packs or even special rare packs (that have slightly boosted numbers - or even guaranteed) for different tasks/streaks/achievements etc. + +In terms of how we want to calculate rarity and mathematics, I want to make it so that if you want to collect every single card yourself, it should take something like at least a year of consistent with doing each task. + * if you do collect all cards (regardless of quality, etc), I think i might be cool to have some kind of prestige system. Maybe decorates profile/binder? + +I was thinking that if you somehow are able to complete the full compendium (not the types) then you can do it in like 1 year. (subject to change depending on feedback) + +#### Binders + +Everyone gets a binder that holds their entire compendium that they can look through (or others can look at) + +Later more binders and different kinds of card holders can be unlocked from achievements or small microtransations - I haven't decided + * This would allow people to collect different types (all metal, all holo, all foil, etc, etc) + +#### The Packs + +* Streaks + * Weekly/monthly streaks will provide higher quality packs. + +* Not sure yet but I would want different kinds of packs that provide boosted chances at the better tiers/values + * Common - normal color foil + * Gold + * Illuminesent + +* Different packs would have different opening animations +* You can choose to have all in a row and clicking each card to flip them + * Or you can have them stacked and flip them going from front to back. + * You can also have just auto open which just opens and start flipping all of them in an animation. + +#### The Cards + +* Different flavors of cards (different decks) - they would share common cards (common not as in tier but rather common theology) + * Catholic + * Orthodox + * Protestant + * Heretic + +* Different Tiers (I want to find better Biblical names for tiers) + * Common + * Uncommon + * Rare + * Extraordinary + * Legendary + * Trinity - Highest tier - only The Father, The Son, and The Holy Spirit + +* Quality - To make the trading aspect fun and always continuous cards pulled will also roll different qualities on a log scale (or some kind of mathematical scale we think about) - maybe even normal graph for things like the wear + * Wear + * Every card unpacked would have a float value between 1.00 and 0.00. 1 would be prestine but 0 would be very worn out. + * Legendary and Trinity (or I guess if we add any other really high tiers would be float locked to being higher) + * Finish - matte/glossy/textured/holographic/foil + * Very small chance for different kinds of holographic/foils - also float capped + * Printing + * Normal, Borderless, Textless, Boundless + * Material + * Paper, plastic, wood, linen, metal + * Weight + * All cards would be about 100g with light variance but things like holographic/foil would add a minor amount of weight + * We could have an achievement system that let's you get a small digital scale so you can weigh the pack to make for more fun unpacking. + * Imperfections + * This one is still a maybe - we can generate a couple of masks that look like finger prints, hair, etc. for the card that has a very small change to be added to imperfections + * Trinity cannot have imperfections. + +* Content + * I would want to shoot to have a very large repository of cards + * The cards would have different categories: people, history, icon, item, place, event, theologions, theorems (apologetics), etc. + * The greater the impact of that card in Biblical history the higher the tier. + * So more obscure people would be put into common (even something like "man") while someone like David would be considered Legendary (could be extraordinary). + * Something like Gabriel or Michael would be also legendary + * Unless a specifically type of card (like textless), every card would have some kind of text associated with it + * The text must be something related to that card - whether a verse, quote, or historical fact (priority in that order) + * The text would also have a link when clicked on to allow you to learn more or see why. + +* Cards also retain metadata about when it was open and who opened it. Maybe we could also track how many cards of that kind (card id/finish/printing/material) - with this you can tell if you have a 1 of 1 metalic holographic normal print Adam or if there were already 1000 pulled. Weight/wear/imperfections wouldn't go into this. + +* Art style + * I still haven't decided how I want the art/cards to look. Just need to AI generate a few options to get a common design + * Card favors would affect the colors, borders of the cards, and back art of the cards. + +### Habit tracking + +Ultimately this would be habit tracker at its base. + +#### Honor system + +I think an important aspect to outline is that ultimately this is a tool to build essential habits with God and the community/fun aspect is second nature. +How or whether someone actually is doing what they are tracking is between them and God - and with accountability partners if they choose to do that. + +What is means to complete a task is also between the user and God. If prayer for them means 5 minutes or 1 hour, that is fine. BUT I think we should outline (maybe during first time) that trying to say a 5 second prayer before entering the app is not qualification. It is a matter of the heart - not to gamify things. + +#### Positive Habit Tracking + +I think initially we will start with a couple of base good habits to build on: +* Prayer +* Bible reading +* Weekly church attendance - Weekly tasks would give a different kind of pack - or maybe we just give you the option to chose a type of common pack +* Study - this would be something like a devotional, a chapter of christian literature, or digging into theology +* Volunteering ?* - not sure how to track this cadence. Doesn't feel like a daily thing but also a category of things that builds a christian value + +You can always log up to the day prior to recieve your card pack + * You can always log previous days/weeks of progress towards your habits but you will not recieve packs for those. (Streaks will also be reset) + +#### Negative Habit Tracking + +I want there to be an option to track bad habits that someone may have. These can be custom input although we will provide some base line ones like: lust, addiction (smoking, drinking), anger, etc. + +This will track separately from the positive habits that the user is trying to break. +They can open up the exact issues to accountability partners or more generic message "Like, X user needs your prayer :)" + +As for if you do track your negative habits, whenever you break your streaks, you will need to burn one of the cards at random. That card would be gone forever. +My thought on this is it would help provide a tangible reaction to what happens. + +BUT after each broken streak, we would provide messaging that the user is still loved and Christ died for our sins but we must repent and choose to grow with Christ + +### Social Interaction + +The other important aspect of card collections is trading. +In general having a community that you can hook into. Where you can have friends and even accountability partners (friends don't necessarily mean accountability partners as some people might rather choose to keep anonimity) + +No chat integration - this would require too much moderation and other things that can take away resources. +You can send each other pre-determined messages of encouragement, you can send any verse (I guess you can technically make it bad but I think we will trust in good faith for this), or pre-determined messages to help with + +Functionalities I think would be great: +* View other people's compendium. +* Offer trade +* All habit tracking will be private except can be opened up to accountability partners. + * This avoids hubris or shame - that is not the point of the project. + +#### Integrations + +I was thinking to provide API and webhook support so it can be integrated into anything at that point. Something like a Fluxer bot (Discord also) + +Other integrations I am not sure yet. + +### Application + + +Reorganize to: + +1. Product principles +2. User journeys +3. Core gameplay loop +4. Habit model +5. Reward/economy model +6. Card definition +7. Card instance/variant model +8. Collection/binder +9. Social/trading +10. Accountability/privacy +11. Christianity/content policy +12. Monetization principles +13. MVP +14. Future systems +15. Open questions + + +Application +└── React / Next.js + ├── Normal UI + │ ├── habits + │ ├── profile + │ ├── trading + │ ├── settings + │ └── binder navigation + │ + └── React Three Fiber + └── Three.js + ├── card inspector + ├── pack opening + ├── binder card interactions + └── collection showcase + +Asset pipeline +└── Blender + └── GLB / glTF + ├── geometry + ├── base materials + ├── normal maps + └── other PBR maps + + +### Card Interaction + +Cards should be represented as tactile physical objects rather +than static collectible images wherever practical. + +When inspecting a card, the user must be able to: + +- Rotate the card freely. +- Flip between front and back. +- Zoom in to inspect artwork and physical characteristics. +- Observe material properties responding dynamically to lighting. +- Observe wear and imperfections where applicable. +- Return quickly to the originating collection/binder context. + +Card finish and material must have visually distinguishable +properties. For example, foil, holographic, paper, linen, and +metal cards should respond differently to lighting. + +Lower-power devices must be able to fall back to simplified +visual representations without affecting gameplay. + +## Initial Technical Direction + +Frontend: +React / Next.js + +3D: +Three.js via React Three Fiber + +Asset authoring: +Blender + +Asset format: +glTF / GLB + +Rendering: +WebGL2 baseline +WebGPU optional + +Card architecture: +Shared geometry + runtime textures/material parameters + + + + + + + +honesty vs. the economy + * I don't think this is as big of a problem. I would build the rarity and economy as if everyone did earnestly do every single task and complete every streak + * If people miss or don't, then the economy would just not develop as quickly + * If people lie, that is fine because that is technically my baseline (as I want everyone - including myself - to be in a place where you can truthfully say you take care of it all the time) + * "Five minutes of prayer shouldn't mint less valuable cards than sixty minutes, because you'd immediately incentivize exaggerated reporting." + * Yeah. I thought that is what I outlined in the functional spec. We would give guidance in messaging but a whole day in prayer doesn't give you anything more than 5 minutes or lying. But it is habit tracking afterall. + +Privacy + * I think we just need to ensure encryption on all customer data. Server cannot read customer content. We can do statistics but things like sin/anethema would be encrypted. Maybe something like BYOK to ensure server admin really can't just look in the DB + * Accountability is obscure by default. + +Negative-habit punishment + * I actually fully agree with you on this. + * I like both the Abstinence streak and the honesty/engagement aspect + * I like recovery milestones + * Every fall needs to be met with grace and good messaging + * Every milestone needs to be celebrated (not with packs - we don't want to promote any kind of side thing) + * I still like the concept of burning the card but ceremoniously or voluntarily would be great! + +Streaks + * You're right. I don't think we do streaks - consistency is a stronger metric. + +Shared core plus traditions + * I like this. Puts strong emphasis on learning but keeps the divide + * I do want to make sure to keep things like Mormonism and JW (for example), in the controversies + +Trinity + * This is just something I was initially rumiating about; I agree that it doesn't have a place in this as I wrote originally. + * What if instead - during the tutorial (or if you skip tutorial) - the Trinity would be given to you as your first and permanent card. + * This symbolizes the core aspect that Christ is always with us and is a gift we do not lose (theology here obviously differs but point stands). + * You can customize it to whichever finish etc you like. It will always be in mint condition. + * Thoughts on verse? I almost think John 3:16 - seems like a pretty decent fit. + * But having Trinitarian other concepts is nice too but I think the meaning here is good. + +Rarity should probably not mean spiritual importance + * I agree on the prominence within the collection + * It was an initial idea I wanted to start with but didn't come to a full idea of what that might be + * "And perhaps some sets have independent rarity structures." <- I think this is great too. + +Monitization + * I genuinely have no real plan to make money off this. I half mentioned it if this were to somehow blow up in popularity. + * My current plan is to build it out and try it with my friends + * ABSOLUTELY no loot boxes. Gambling already ruins lives and that would be an anethema to what I'm trying to accomplish + +Login pack + * I still need to deliberate on how I want to give packs and how many cards per pack and statistics behind that. + * Login pack helps establish the initial hook. When in the app (or could before), you now might realize, "Oh right. I need to actually accomplish my tasks" + * I'm also still not sure if we should give pack per task or upgrade the pack per task + * Hmmm. What if that is the case? The login pack has reaally bad odds. You do your dailies and other tasks and it upgrades your pack. + * This way you still have the hook, if they choose to just open it - the odds are bad or maybe we just heavily increase the wear chance. + +Random thoughts + * You can also choose to save packs to open later - if you want to open many at once or on occasion. + +MVP + * I like the idea of fleshing out the MVP. + * I'm a software engineer - not an artist or 3D modeler - so I'll be leaning on LLMs (MCP servers) for help on those pieces + * There is not true deliverable so I think just making like 50 cards but trying to do all the difficult stuff with the cards is what is interesting to me + * The canvas and rendering will also be interesting tech challenges + * Wiring up a backend to client and handling all those other pieces are much easier to me and will be addressed in tech spec + + +Product philosophy + * Completion/honesty/consistency/return from failure are all core concepts + +Is the collectible system subordinate to the habits, or equally important? + * I think the collection system is what makes the habit tracking unique - if I wanted habit tracking, I can do with pen/paper or just download any one of the 1000 apps that already do so. + +Christianity + Who determines whether card content is doctrinally acceptable? + Is the standard strictly Nicene? Yes - for now. Subject to change + + Would a Catholic-specific card be visible to an Orthodox user? Yes + Can users choose traditions? No - maybe they can change UI to match tradition (addon?) + Can users collect every tradition? Yes - I want people to also learn + +What constitutes a unique card? + CardIdentity + Artwork + Rarity + Finish + Printing + Material + Wear - but in percentages so Mint might be like 1.00 - .95, Minimal Wear .95 - .75, etc etc, + +Economy + * I think those can come in the future - Let's work on this area specifically + * Maybe we have a way to allow essentially destruction of cards - use up like 20 common cards to generate a uncommon. Similar to CounterStrike trade ups + * Allows for also removing things from the pool + +Are pack probabilities globally fixed or dynamic? + * Fixed - maybe we wire in the variables to allow for dynamic in future if we feel the numbers aren't working as expected + +What experience do you want someone to have the morning after they fail badly? + * We treat with grace. Tell them that we are excited to have them back and every journey begins with the first step + +Structural change + * I like the structural change. + +I like the idea for the technical spike of flutter scene versus Three.js \ No newline at end of file diff --git a/functional-spec-v2.md b/functional-spec-v2.md new file mode 100644 index 0000000..c8b8cb7 --- /dev/null +++ b/functional-spec-v2.md @@ -0,0 +1,1086 @@ +# Christian Habit Tracker TCG - Functional Spec v2 + +I think a good name for the application would still be **Sanctification TCG**; as the objective is to grow in Christ and move towards holiness. + +Habit trackers are super common place and incredibly overdone. I find the same issue with every single one of them - tracking your habits needs to be a habit itself. Many applications try to add a "carrot" but nothing actually seems to provide a good sense to really reel you back in. + +On the opposite front, bad habits also tend to just be a number or a streak. If you fail, the app resets the number and that is about it. I want this to feel more human than that and I especially do not want failure to become a reason to avoid opening the app again. + +One thing that has become hugely popular is TCGs - more importantly card openings. This idea that you can collect the cards, try to obtain rare instances, collect wide variations of the same card, trade with friends, and just enjoy the experience of opening something you earned. + +All these card games are typically built in their own universe - but I want to do it Christian themed. + +The collection system is not really secondary to the habit tracker. It is the thing that makes the habit tracker unique. If I just wanted to track habits I could use pen and paper or download one of the thousand other habit trackers that already exist. The cards are the hook that should make me want to come back and keep using it. + +Below breaks this down into the larger functional pieces of the project, the things I think are already decided, and the unknowns that still need to be explored. + +--- + +## Product Principles + +### Core Purpose + +Ultimately this is a tool to help build essential habits with God. The collecting, trading, opening animations, social aspects, etc. are all there to make that process fun enough that someone actually wants to keep coming back. + +I think four concepts are equally important here: + +* Completion - actually doing the habits you said you wanted to do. +* Honesty - being truthful with yourself about whether you actually did them. +* Consistency - building something sustainable over time rather than obsessing over a perfect streak. +* Returning from failure - if you fall off for a day, week, month, or longer, the application should make you want to come back instead of making you feel like everything was lost. + +### Honor System + +How or whether someone actually is doing what they are tracking is between them and God - and with accountability partners if they choose to do that. + +What it means to complete a task is also between the user and God. If prayer for them means 5 minutes or 1 hour, that is fine. I think we should outline during onboarding that trying to say a 5 second prayer before entering the app is not really qualification. It is a matter of the heart - not trying to min/max the game. + +At the same time, the app should not try to grade the quality of someone's prayer, Bible reading, church attendance, etc. + +Five minutes of prayer should not give a worse pack than sixty minutes. A whole day of prayer should not give more rewards than an earnest five minutes. Habit completion is binary from the application's perspective. + +### Honesty vs. the Economy + +I don't think we should build this like an anti-cheat system. + +The rarity and economy should be modeled around the assumption that everyone earnestly completes every task and reaches the consistency targets available to them. That is basically the maximum expected supply. + +If people miss days or do not complete everything, the economy simply develops more slowly than that baseline. + +If someone lies and clicks every task anyway, that is technically just reaching the baseline we already modeled for. It is between them and God, and I do not want the app to turn into a verification system for prayer or Christian practice. + +Because of that, rewards should also never scale based on self-reported duration or difficulty. We should not create an incentive to claim that a five minute prayer was a two hour prayer because it gives better cards. + +### Privacy + +Habit tracking is private by default. + +This becomes especially important with negative habits because the user may be putting extremely sensitive information into the application. + +I want sensitive customer content to be encrypted in a way where the server cannot casually read it. Statistics can still exist where possible, but content such as a user's sins, struggles, custom habit names, notes, etc. should not just be plaintext sitting in the database for a server administrator to inspect. + +Exactly how we accomplish this is more of a technical-spec question. Client-side encryption and potentially some kind of BYOK design are worth exploring. The important functional requirement is that private content should be private even from us as much as reasonably possible. + +Accountability sharing should also be obscure by default. A user should be able to say something like: + +> Daniel needs your prayer today. + +without automatically telling the accountability partner exactly what happened. + +The user can explicitly choose to share more detail if they want to. + +--- + +## Christianity + +At the core foundation, what we consider Christianity for the purposes of this application is currently founded in the words and logic of the Nicene Creed. + +This is the standard **for now** and can be revisited later if needed. + +> We believe in one God the Father Almighty, Maker of heaven and earth, and of all things visible and invisible. +> +> And in one Lord Jesus Christ, the only-begotten Son of God, begotten of the Father before all worlds, God of God, Light of Light, Very God of Very God, begotten, not made, being of one substance with the Father by whom all things were made; who for us men, and for our salvation, came down from heaven, and was incarnate by the Holy Spirit of the Virgin Mary, and was made man, and was crucified also for us under Pontius Pilate. He suffered and was buried, and the third day he rose again according to the Scriptures, and ascended into heaven, and sitteth on the right hand of the Father. And he shall come again with glory to judge both the quick and the dead, whose kingdom shall have no end. +> +> And we believe in the Holy Spirit, the Lord and Giver of Life, who proceedeth from the Father, who with the Father and the Son together is worshiped and glorified, who spoke by the prophets. And we believe in one holy catholic and apostolic Church. We acknowledge one baptism for the remission of sins. And we look for the resurrection of the dead, and the life of the world to come. Amen. + +### Shared Core Plus Traditions + +Rather than treating Catholic, Orthodox, Protestant, etc. as completely separate decks, I think it makes more sense to have a shared Christian core and then tradition-specific collections around it. + +The shared core can contain things such as: + +* Scripture +* Biblical people +* Biblical places +* Biblical events +* Early church history +* Major ecumenical councils +* Core Christian doctrines +* Things generally shared within Nicene Christianity + +Tradition-specific collections can then include: + +* Catholic +* Orthodox +* Protestant +* Potentially more specific traditions later + +A user does **not** pick one tradition and get locked into it. A Catholic-specific card can be visible to and collected by an Orthodox or Protestant user, and vice versa. + +I want people to be able to collect every tradition because part of the point is also learning what other Christians actually believe and where traditions differ. + +Maybe the user can choose a UI/theme that matches their tradition at some point, but that would be visual and not limit the cards they can collect. + +### Controversies / Non-Nicene Movements + +I still want historical heresies and theological controversies represented because they are genuinely interesting and useful to learn about. I just don't think a generic "Heretic Deck" is the best structure. + +This can include things such as: + +* Arianism +* Gnosticism +* Pelagianism +* Nestorian controversies +* Mormonism / Latter-day Saint theology +* Jehovah's Witnesses +* Other non-Nicene or historically disputed movements + +The card should explain what the person/movement taught, why it became controversial, and how it differs from the Nicene standard we are using for the application. + +The card cannot contain all the information so we can also make sure to have a panel for more information or resource links to learn more about it. It can help explain the position, why it matters historically/theologically, and how it compares to the Nicene standard. They aren't insult cards. + +This should be educational rather than just putting a big "HERETIC" stamp on people and calling it a day. + +### The Trinity Card + +I no longer think The Father, The Son, and The Holy Spirit should be the highest collectible rarity tier. + +I do still really like the idea of the Trinity having a unique place in the application though. + +One idea is that during the tutorial - or immediately after skipping the tutorial - every user is given **The Trinity** as their first and permanent card. + +This card would be different from every other card in the system: + +* It is given, not earned. +* It cannot be traded. +* It cannot be destroyed. +* It is always considered mint condition. +* The user can customize the finish/material presentation however they like without affecting rarity or the economy. +* It does not participate in normal rarity or population mechanics. + +The symbolism I like here is that the most important thing in the collection is something you did not grind for or earn. It was given to you and you cannot lose it. + +The exact theological messaging obviously needs some thought because traditions differ on some of the implications there, but I think the overall meaning is good. + +**Initial verse thought:** John 3:16. It seems like a pretty decent fit for the idea of something given rather than earned. + +The artwork for this card also needs special consideration. I do not want us to accidentally make some weird literal depiction of the Father / Son / Holy Spirit just because we need card art. + +--- + +## Core Application Loop + +The rough loop is: + +1. Open the application. +2. See today's habits and current pack state. +3. Complete and report habits honestly. +4. Improve the day's reward / pack. +5. Open it now or save it for later. +6. Collect cards and variants. +7. Inspect, organize, learn from, and eventually trade cards. +8. Come back tomorrow. + +The exact pack reward model still needs to be worked through. + +--- + +## Habit Tracking + +Ultimately this is still a habit tracker at its base. + +### Positive Habit Tracking + +I think initially we will start with a couple of base good habits to build on: + +* Prayer +* Bible reading +* Weekly church attendance +* Study - something like a devotional, a chapter of Christian literature, digging into theology, etc. +* Volunteering ?* - I am still not sure how to track this cadence. It doesn't feel like a daily thing but it is also obviously a category that builds Christian values. + +Custom habits can come later or be included early depending on how much work it ends up being. + +You can log up to the day prior and still receive whatever reward that day qualified for. + +You can always log older days/weeks of progress toward habits for your own records and consistency statistics, but they should not generate retroactive packs indefinitely. + +### Consistency Instead of Streaks + +I don't think positive habits should revolve around streaks anymore. + +A streak makes one missed day disproportionately destructive. Going from 93 days to "0" is a terrible way to represent someone who completed something 93 out of the last 94 days. + +Consistency is a much stronger metric. + +We can track things such as: + +* 7-day consistency +* 30-day consistency +* 90-day consistency +* Lifetime completion rate +* Total completions +* Returning after time away + +The exact windows and which ones affect rewards are still subject to change. + +This also gives us more interesting achievement opportunities without making the user feel like one missed Tuesday erased three months of work. + +### Negative Habit Tracking + +I want there to be an option to track bad habits that someone may have. These can be custom input although we can provide some baseline categories/examples like lust, addiction, smoking, drinking, anger, etc. + +This tracks separately from the positive habits the user is trying to build. + +For negative habits, I think there are two useful measurements: + +#### Abstinence + +How long has it been since the behavior occurred? + +This can still naturally be represented as a streak because the elapsed time itself is meaningful here. + +#### Honesty / Engagement + +Is the user continuing to truthfully track, return, and work on the problem? + +This should **not** reset when someone fails. + +If someone falls after 100 days and reports it honestly, we should not turn that into: + +> You failed. Everything is gone. Start over. + +The abstinence count resets because that is simply factual, but the user's larger journey does not. + +### Recovery Milestones + +After a fall, I like the idea of recovery milestones: + +* First day back +* Three days back +* One week back +* Other meaningful points we decide on + +Every milestone should be celebrated, but **not with card packs or rarity rewards**. I do not want to accidentally create some weird side incentive where failure becomes part of an optimal reward strategy. + +The celebration can be messaging, visual acknowledgement, Scripture, encouragement, etc. + +### Failure Messaging + +Every fall needs to be met with grace and good messaging. + +The experience I want the morning after someone fails badly is basically: + +> We are glad you're back. You are still loved. Christ died for our sins. Repent, get back up, and keep moving forward. Every journey begins again with the first step. + +The exact wording will need theological/content review later, but that is the tone. + +The application should never make the user feel like they should avoid opening it because they are ashamed of what happened. + +### Voluntary Card Burning + +I still like the visual and ceremonial idea of burning a card. I just don't want it attached to mandatory punishment for honestly reporting a failure. + +Maybe card burning becomes voluntary. + +Possible uses: + +* A user voluntarily burns a card as part of some personal ceremony / reset. +* Burning duplicates becomes part of the card economy. +* Cards can be consumed in future crafting/trade-up systems. + +The exact implementation is still open. + +--- + +## TCG / Collection + +The whole premise of the "game" is collection. This is the carrot that makes the habits more interesting and hopefully gives the application a real community around it. + +There is no actual card combat system planned. The fun is collecting, opening, inspecting, organizing, trading, learning, and hunting for rare variants. + +### Collection Completion + +I still like the general idea that collecting one of every base card by yourself should take something like at least a year of very consistent usage. + +That number is very subject to change once we actually model the economy and start testing it. + +If someone completes the full base compendium, regardless of finish/material/etc., I think some kind of prestige system could be cool. + +Maybe it decorates the profile or binder rather than just resetting everything. + +### Binders + +Everyone gets a binder that holds their entire compendium that they can look through. + +Other people can look through a user's public collection/binder if that user allows it. + +Later we can have different binder designs and different ways to organize cards. This would let someone make dedicated collections such as: + +* All metal cards +* All holographic cards +* All foil cards +* All Catholic cards +* All church-history cards +* Whatever other collection they want to show off + +I previously mentioned microtransactions here, but there is currently no monetization plan. If this somehow becomes large enough that monetization matters, cosmetic binder/profile customization could be revisited later. + +### Packs + +I still need to deliberate on exactly how packs are awarded, how many cards each pack contains, and all of the statistics behind them. + +#### Working Daily-Pack Idea + +I still like having some kind of login pack because it establishes the initial hook. + +The user opens the app and sees the pack, which can immediately remind them: + +> Oh right. I need to actually accomplish my tasks today. + +One idea I like more than simply giving one pack per task is that the daily pack **upgrades as tasks are completed**. + +For example: + +* Login / start of day - very weak pack or only a couple cards. +* Complete one task - improve the pack. +* Complete additional tasks - improve odds, card count, or pack quality. +* Complete everything for the day - reach that day's maximum pack state. + +This keeps the login hook without making simply opening the application equivalent to actually doing the habits. + +I am not sure yet whether task completion should: + +* Improve rarity odds. +* Increase the number of cards in the pack. +* Improve expected wear / quality. +* Upgrade the pack type itself. +* Use some combination of the above. + +I particularly like the idea that the initial login pack might only have something like two cards and completing tasks adds additional cards to it. That may feel less casino-like than constantly manipulating rarity odds, but we need to test it. + +#### Saving Packs + +Users can choose to save earned packs and open them later. + +Some people may want to open every day. Other people may want to save ten or twenty packs and have one larger opening session. + +Once a day's pack is finalized, saving it should not continue to improve it later unless we intentionally design a mechanic around that. + +#### Pack Odds + +Pack probabilities should initially be globally fixed. + +We should still wire the probability variables in a way where they can be adjusted later if testing shows that the economy is developing too quickly or too slowly. + +I do **not** want probabilities dynamically changing per user behind the scenes just to manipulate engagement. + +#### Pack Types + +I still think different types of packs could be fun, with different expected card qualities or presentation. + +Initial placeholder ideas: + +* Common / normal +* Gold +* Illuminescent + +Names and exact meaning are still TBD. + +Different packs can have different opening animations. + +### Pack Opening Experience + +Opening cards is one of the most important parts of the entire application, so I think this is an area where we can intentionally be a little excessive. + +Possible opening modes: + +* Cards in a row, clicking each one to flip. +* Cards stacked front-to-back and revealed one at a time. +* Auto-open / open all for when the user does not care about the full animation. + +Rarity can affect anticipation without going full casino. + +For example, higher-rarity cards can have different pacing, lighting, sound, or reveal animation, but I do not want the application screaming flashing slot-machine effects at people. + +The reveal should feel special because the card is special, not because we are trying to mimic gambling psychology. + +### Rarity + +I still want different tiers, although I want to find better Biblical/thematic names for them eventually. + +Placeholder tiers: + +* Common +* Uncommon +* Rare +* Extraordinary +* Legendary + +Rarity should represent **prominence within the collection**, not spiritual worth, holiness, or importance to God. + +I also really like the idea that different sets can have independent rarity structures. + +For example, rarity within a Biblical People set does not necessarily need to mean the exact same thing as rarity within a Church History set or a Catholic collection. + +This needs more work when we start building the actual card catalog. + +### What Makes a Card Unique + +At a high level, I think a card instance is made up of things such as: + +* Card Identity +* Artwork +* Rarity +* Finish +* Printing +* Material +* Wear +* Potential imperfections +* Provenance / who originally opened it and when + +There should still be a clean distinction between the base **card definition** and an individual **card instance**. + +For example: + +```text +Card Definition + David - Card 001 + Artwork A + Legendary + Biblical People Set + +Card Instance + David - Card 001 + Holographic + Borderless + Metal + 97.3% condition + Opened by Daniel + Opened on +``` + +That general contract should be renderer-independent. The database should not care whether Three.js, Flutter Scene, or some future engine is displaying the card. + +### Finish + +Possible finishes: + +* Matte +* Glossy +* Textured +* Holographic +* Foil +* Potentially multiple kinds of holographic/foil patterns later + +Different finishes should be visually obvious when interacting with the card. A holographic card should not just have a little "Holographic" tag underneath it. + +### Printing + +Placeholder printing types: + +* Normal +* Borderless +* Textless +* Boundless + +These can materially alter the card artwork/layout rather than just being metadata. + +### Material + +Possible materials: + +* Paper +* Plastic +* Wood +* Linen +* Metal + +Material should affect the way the card looks and reacts to light. + +### Wear / Condition + +I still like every normal card instance having a wear/condition value between `0.00` and `1.00`, but I am reconsidering what the low end actually looks like. + +`1.00` would be essentially pristine. + +`0.00` does **not** need to mean the card looks like it went through a washing machine. It could still be clearly readable and attractive, just visibly scratched / worn / damaged compared to a mint card. + +We can expose condition to users as percentages and named bands. + +Very rough example: + +* Mint: `1.00 - 0.95` +* Minimal Wear: `0.95 - 0.75` +* Additional bands: TBD + +The actual ranges need to be modeled and tested rather than guessed here. + +The wear value should affect visible rendering where practical: + +* Edge whitening +* Surface scratches +* Small scuffs +* Roughness changes +* Other subtle damage + +Even poor-condition cards should still look like something someone wants to collect. + +### Weight + +I still like the completely unnecessary physical-card idea that cards have simulated weight. + +Cards might have a baseline around 100g with slight variance, while material/finish can add or subtract minor amounts. + +Eventually there could be an achievement or unlockable digital scale that lets the user weigh an unopened pack and try to infer what might be inside. + +This is very much a future fun feature and not core functionality. + +### Imperfections + +This is still a maybe. + +We could generate some number of masks for things like: + +* Fingerprints +* Hair +* Minor print artifacts +* Surface marks + +Then an individual card instance could have a very small chance of receiving one based on a deterministic seed. + +This would make otherwise-identical variants slightly more interesting. + +### Population / Provenance + +Cards should retain metadata about when they were opened and who originally opened them. + +I also like tracking population for meaningful combinations such as: + +```text +Card ID + Finish + Printing + Material +``` + +That way someone can see that their metallic holographic borderless Adam is 1 of 1, while another combination may already have 1,000 copies. + +I don't think wear, weight, and individual imperfections should count toward this population grouping because then effectively everything becomes a fake 1-of-1. + +### Card Economy / Destruction + +This is one area I want to work on more specifically. + +One mechanic that might be useful is some kind of trade-up / crafting system similar in spirit to Counter-Strike trade-ups. + +For example: + +> Consume 20 Common cards to generate 1 Uncommon card. + +The exact number and output obviously need to be modeled. + +I like this because it gives duplicates a use while also permanently removing cards from circulation. + +Questions we need to answer later: + +* Is the output guaranteed to be the next rarity? +* Does finish/material influence the result? +* Can users choose which set the resulting card comes from? +* Are some cards protected from trade-ups? +* Does a burned/traded-up card remain in provenance history as destroyed? + +I think actual destruction can become an important sink in the economy, but it should mostly be voluntary rather than punishment. + +### Content + +I want to eventually have a very large repository of cards. + +Possible categories include: + +* People +* History +* Icons +* Items +* Places +* Events +* Theologians +* Theology +* Apologetics / arguments +* Councils +* Traditions +* Controversies +* Probably many more once we actually start cataloging things + +Unless it is a specific printing such as textless, every card should have meaningful text associated with it. + +Priority for card text: + +1. Scripture where directly relevant. +2. Quote where directly relevant. +3. Historical / theological fact. + +There should also be a way to click through and learn more or understand why the text/artwork is associated with that card. + +The educational side is important. Pulling something unfamiliar should be an invitation to learn what it is rather than just seeing a rarity number. + +### Art Style + +I still have not decided how I want the cards themselves to look. + +This is something where I will probably lean heavily on LLM/image-generation tooling and MCP servers to help explore art direction since I am a software engineer, not an artist or 3D modeler. + +I think we should generate a bunch of visual directions before committing to one common design language. + +Tradition/set flavor can affect things such as: + +* Colors +* Borders +* Typography +* Card backs +* Ornamentation +* Maybe 3D material treatment + +--- + +## Visual Experience + +The visual experience is not just decoration for this application. Opening and physically inspecting the collectible is part of the reward. + +### Card Interaction + +Cards should be represented as tactile physical objects rather than static collectible images wherever practical. + +When inspecting a card, the user must be able to: + +* Rotate the card freely. +* Flip between front and back. +* Zoom in to inspect artwork and physical characteristics. +* Observe material properties responding dynamically to lighting. +* Observe wear and imperfections where applicable. +* Return quickly to the originating collection/binder context. + +Card finish and material must have visually distinguishable properties. For example, foil, holographic, paper, linen, and metal cards should respond differently to lighting. + +Lower-power devices must be able to fall back to simplified visual representations without affecting gameplay. + +### Where 3D Is Used + +I do **not** think every card in every screen should be an actively rendered 3D object. + +The primary 3D use cases should be: + +* Pack openings +* Full card inspection +* Potentially special collection/showcase views later + +Normal binder pages, search, trading lists, etc. can use static or pre-rendered thumbnails until the user chooses to inspect a card. + +This should keep the application responsive while still making the moments that matter visually interesting. + +### Visual Properties Should Matter + +If we add a collectible property, the user should ideally be able to see or interact with it somehow. + +For example: + +```text +Finish -> visible +Material -> visible +Wear -> visible +Imperfections -> visible +Printing -> visible +Weight -> indirectly observable +Provenance -> inspectable metadata +Population -> inspectable metadata +``` + +Otherwise there is not much point in storing increasingly complicated metadata that never changes the actual experience. + +--- + +## Social Interaction + +The other important aspect of card collections is trading and having some kind of community around them. + +Users can have friends and accountability partners. Those are intentionally different concepts because someone may want to trade/show collections to a lot of people while keeping accountability very private. + +### Initial Social Functionality + +Things I think would be great: + +* View another user's public compendium / binder. +* Offer trades. +* Add friends. +* Add explicit accountability partners. +* Send predetermined messages of encouragement. +* Send Scripture / verses. +* Send generic prayer requests without revealing why. + +### No General Chat + +I do not want general chat integration. + +That creates a massive moderation problem and can very quickly turn into the application becoming a social network instead of the thing I actually want to build. + +Predetermined messages and controlled interactions should cover most of what is useful here without adding an entire moderation product. + +### Accountability + +All habit tracking is private unless explicitly opened to accountability partners. + +Even then, the default shared state should be obscure rather than detailed. + +For example: + +> Daniel asked for prayer today. + +rather than: + +> Daniel failed Habit X at 9:42 PM. + +The user can choose to share exact habits/details with a specific accountability partner if they want to. + +This avoids both hubris and shame - neither of those is the point of the project. + +--- + +## Monetization + +I genuinely have no real plan to make money off this right now. + +My current plan is to build it, use it myself, and try it with friends. + +If it somehow blows up in popularity, then infrastructure costs and monetization can be figured out later. + +One thing I do want to make an explicit product principle now: + +**Absolutely no paid loot boxes or buying better pack odds.** + +Gambling already ruins lives and building that psychology into an application that is supposed to help people grow in Christ would be anathema to what I am trying to accomplish. + +If monetization ever becomes necessary, things like purely cosmetic binder designs, profile customization, or other non-random features can be considered separately. + +Money should not buy spiritual-habit rewards or manipulate rarity odds. + +--- + +## Integrations + +I still think API and webhook support would be useful eventually so the application can integrate into other things. + +Examples: + +* Fluxer bot +* Discord bot +* Other automation / personal tooling + +Exactly what gets exposed and when is a technical/API-spec question. + +This is not a priority for the first MVP. + +--- + +## MVP + +There is not really a formal deliverable or deadline right now. This is a project I want to build because the product is interesting and some of the technical problems are fun. + +Since I am a software engineer and not an artist or 3D modeler, I expect to lean on LLMs, MCP servers, image generation, and other tooling to help with those parts. + +I think the MVP should intentionally focus on the difficult / interesting pieces rather than spending months filling out content. + +### Initial MVP Scope + +Something like: + +* Web client first. +* Works on desktop and mobile browsers. +* Basic account/authentication. +* Positive habit tracking. +* Negative habit tracking with abstinence + honesty/engagement concepts. +* Consistency metrics instead of positive-habit streaks. +* Daily pack/reward prototype. +* Ability to save packs. +* Around 50 real cards. +* Multiple rarity levels. +* Multiple finishes/materials/printings. +* Wear rendering. +* Binder / compendium. +* Full 3D card inspection. +* 3D pack-opening flow. +* Basic population/provenance model. +* Enough economy simulation to begin testing rarity math. + +I would rather have **50 cards where all of the difficult rendering/material/variant systems actually work** than 500 cards that are basically static images. + +The rendering canvas, shaders/materials, and card pipeline are some of the more interesting technical challenges to me. + +Wiring up a backend to a client, normal CRUD, auth, API calls, etc. are much more familiar problems and can be fleshed out separately in the technical spec. + +### Not Required for the First MVP + +Likely later: + +* Native Android/iOS applications. +* Full trading economy. +* Advanced social functionality. +* API/webhook ecosystem. +* Huge card catalog. +* Digital pack scale / weight mechanics. +* Deep achievement/prestige systems. +* Monetization. + +### MVP art-direction rules + +Seen in [Art Direction](./art-direction.md) + +### Sample MVP Collection + +#### Permanent starter + +| ID | Card | Type | Notes | +| ----- | --------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S-001 | **The Trinity** | Foundation | Permanent, nontradeable, cannot be burned or consumed, always mint. User can choose/customize finish/material/printing. Suggested verse: Matthew 28:19. | + + +#### Others + +| # | Card | Category / Set | Working Rarity | Content / Visual Hook | +| --: | --------------------------------- | --------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 001 | **Adam** | Scripture · Person | Rare | Genesis 2–3. Excellent portrait/environment card; Eden imagery. | +| 002 | **Eve** | Scripture · Person | Rare | Genesis 2–3. Companion visually to Adam without making them a paired requirement. | +| 003 | **Noah** | Scripture · Person | Uncommon | Genesis 6–9. Rain, ark, rainbow imagery. | +| 004 | **Abraham** | Scripture · Person | Extraordinary | Genesis 12–22. Stars/sky imagery could make an excellent foil. | +| 005 | **Sarah** | Scripture · Person | Uncommon | Genesis 18, 21. | +| 006 | **Moses** | Scripture · Person | Legendary | Burning bush / Sinai gives us several strong artwork directions. | +| 007 | **Ruth** | Scripture · Person | Common | Book of Ruth. Wheat-field imagery; intentionally beautiful despite Common rarity. | +| 008 | **David** | Scripture · Person | Legendary | Shepherd/king imagery. Could support multiple artwork printings eventually. | +| 009 | **Elijah** | Scripture · Person | Rare | 1 Kings 17–19. Fire-heavy card effects would be useful renderer testing. | +| 010 | **Isaiah** | Scripture · Person | Rare | Isaiah 6 / prophetic imagery. | +| 011 | **Jonah** | Scripture · Person | Common | Jonah. Strong visual identity even at Common. | +| 012 | **Mary** | Scripture · Person | Extraordinary | Luke 1–2. Could eventually have tradition-specific artwork variants. | +| 013 | **Joseph of Nazareth** | Scripture · Person | Uncommon | Matthew 1–2. | +| 014 | **John the Baptist** | Scripture · Person | Rare | Matthew 3 / John 1. Water/wilderness imagery. | +| 015 | **Peter** | Scripture · Person | Legendary | Gospels / Acts. Keys could appear in some tradition-specific printings without defining the base card around them. | +| 016 | **Paul** | Scripture · Person | Legendary | Acts 9 + epistles. | +| 017 | **Mary Magdalene** | Scripture · Person | Rare | John 20. Resurrection witness gives the card a clear textual focus. | +| 018 | **Stephen** | Scripture · Person | Common | Acts 6–7. | +| 019 | **Timothy** | Scripture · Person | Common | Acts 16 / Timothy. | +| 020 | **Gabriel** | Scripture · Angel | Rare | Luke 1. Visually lets us test a non-human person card. | +| 021 | **Creation** | Scripture · Event | Extraordinary | Genesis 1. Very different composition from portrait cards. | +| 022 | **The Flood** | Scripture · Event | Uncommon | Genesis 6–9. Water/rain shader experimentation. | +| 023 | **The Exodus** | Scripture · Event | Extraordinary | Exodus 12–14. Sea/fire/cloud imagery. | +| 024 | **The Ten Commandments** | Scripture · Event | Rare | Exodus 20. Stone/tablet materials make this particularly useful visually. | +| 025 | **The Nativity** | Scripture · Event | Extraordinary | Luke 2. | +| 026 | **The Baptism of Jesus** | Scripture · Event | Rare | Matthew 3. | +| 027 | **The Last Supper** | Scripture · Event | Rare | Luke 22 / 1 Corinthians 11. | +| 028 | **The Crucifixion** | Scripture · Event | Legendary | Central subject, but rarity represents prominence in this collection rather than "holiness." | +| 029 | **The Resurrection** | Scripture · Event | Legendary | Excellent candidate for one of the MVP's most visually elaborate cards. | +| 030 | **Pentecost** | Scripture · Event | Extraordinary | Acts 2. Fire/light effects. | +| 031 | **The Ark of the Covenant** | Scripture · Item | Rare | Exodus 25. Excellent first metal/gold-material showcase. | +| 032 | **Bethlehem** | Scripture · Place | Common | Micah 5:2 / Luke 2. | +| 033 | **Jerusalem** | Scripture · Place | Rare | Lets us establish how location cards differ visually from person/event cards. | +| 034 | **The Sea of Galilee** | Scripture · Place | Common | Strong environmental artwork; water reflection testing. | +| 035 | **The Empty Tomb** | Scripture · Place | Extraordinary | Resurrection-associated without duplicating the Resurrection event card. | +| 036 | **The Nicene Creed** | Core · Doctrine/History | Legendary | Basically the thesis statement for the app's doctrinal baseline. | +| 037 | **The Council of Nicaea** | Church History · Event | Extraordinary | AD 325. Also introduces historical-event cards outside Scripture. | +| 038 | **Athanasius of Alexandria** | Church History · Theologian | Rare | Natural connection to Nicaea and the Incarnation. | +| 039 | **The Incarnation** | Core · Theology | Extraordinary | John 1:14 provides a strong scriptural anchor. | +| 040 | **The Resurrection of the Dead** | Core · Theology | Uncommon | 1 Corinthians 15 / Nicene Creed. | +| 041 | **The Great Commission** | Core · Teaching | Uncommon | Matthew 28:18–20. | +| 042 | **The Lord's Prayer** | Core · Teaching | Common | Matthew 6:9–13. Good example of a Common card that everyone should still want. | +| 043 | **The Sermon on the Mount** | Core · Teaching | Uncommon | Matthew 5–7. | +| 044 | **The Rosary** | Catholic · Item/Practice | Uncommon | Tests explicitly tradition-specific educational material. | +| 045 | **Christ Pantocrator** | Orthodox · Icon | Rare | Gives the MVP an actual icon card and a very different visual format. | +| 046 | **Martin Luther** | Protestant · Theologian | Rare | Reformation history; explicitly Protestant-tagged while collectible by everyone. | +| 047 | **The Book of Common Prayer** | Protestant · Item/History | Uncommon | Anglican-specific, which also starts proving that "Protestant" doesn't need to be treated as one monolithic tradition. | +| 048 | **Arianism** | Controversies · Theology | Uncommon | Explain what Arius taught, why Nicaea responded, and what the Nicene position is. | +| 049 | **The Latter-day Saint Movement** | Controversies · Movement | Uncommon | Educational treatment; explain where its doctrine of God differs from the app's Nicene standard. | +| 050 | **Jehovah's Witnesses** | Controversies · Movement | Uncommon | Same approach: describe rather than mock, then clearly compare with the Nicene baseline. | + + +I'd choose maybe 10 "hero cards" and deliberately make them exercise the most difficult rendering features: + +| Hero card | Rendering experiment | +| --------------------- | ---------------------------------------- | +| Moses | emissive/fire | +| Elijah | foil + fire | +| Creation | borderless + large environmental artwork | +| Exodus | animated/specular water | +| Resurrection | premium holographic treatment | +| Pentecost | emissive/fire | +| Ark of the Covenant | metallic gold | +| Nicene Creed | text-heavy card | +| Christ Pantocrator | textured/icon treatment | +| Book of Common Prayer | linen/paper/embossing | + + +--- + +## Initial Technical Direction + +The functional requirements should stay mostly engine-independent, but the way the cards look and behave is important enough that we need to consider rendering architecture early. + +### High-Level Architecture + +The web client is the MVP because it gets us something usable on desktop and mobile devices immediately. + +Native Android/iOS are still the north star if the project proves worth continuing. + +```text + Backend / API + | + Shared Card Model + | + +----------+----------+ + | | + Web Client Flutter Client + (MVP) (Android / iOS) + | | + +------+-------+ +------+-------+ + | | | | + Normal UI 3D Renderer Flutter UI 3D Renderer +``` + +The important part is that the **shared card model and backend contracts are renderer-independent**. + +### Card Rendering Contract + +The general idea is that the renderer receives a contract describing what the card is rather than the database storing engine-specific details. + +Very rough example: + +```json +{ + "cardId": "david-001", + "artworkId": "david-a", + "finish": "holographic", + "material": "metal", + "printing": "borderless", + "wear": 0.973, + "imperfectionSeed": 81251 +} +``` + +Then the active renderer interprets that contract. + +That gives us room to use different renderers on different clients without touching the card economy or rewriting the backend model. + +### Asset Pipeline + +Current direction: + +```text +Blender / generated assets + | + v + glTF / GLB + | + v + Runtime Renderer +``` + +Blender would be useful for card geometry, UVs, some material authoring, normal maps, embossing, etc. + +More advanced holographic/foil/view-dependent effects will probably need to be handled in the runtime renderer rather than assuming every Blender shader can just be exported as-is. + +### Renderer Technical Spike - Next Step + +The next major technical question should be a renderer spike comparing: + +#### Three.js + +Likely strongest candidate for the web MVP and probably via React Three Fiber if the web application is React-based. + +Things to test: + +* Holographic materials +* Foil +* Metal +* Wear masks +* Lighting +* Rotation/zoom/touch controls +* Pack-opening animation +* Mobile browser performance + +#### Flutter Scene / Flutter GPU + +Worth testing because native Android/iOS are the eventual goal and a good result here could let us build a native renderer without embedding the web renderer forever. + +Things to test should be the same so we are comparing real output rather than toy demos. + +#### Prototype the Hard Card + +Instead of testing with a plain paper Common card, the technical spike should deliberately build something absurd: + +> Legendary + borderless + metal + holographic + embossed + visible wear + +If both renderers can handle the hardest card nicely, normal cards should be straightforward. + +The spike should compare: + +* Visual fidelity +* Shader/material flexibility +* Performance on desktop +* Performance on mobile +* Touch interaction quality +* Developer ergonomics +* Asset pipeline complexity +* How much renderer code can realistically be shared between web and future native clients + +We can decide the rendering engine after that rather than locking ourselves in beforehand. + +--- + +## Open Questions / Areas to Workshop + +### Pack Economy + +* How many cards are in the initial login pack? +* Does each completed task add cards, improve rarity, improve condition, upgrade the pack, or some mixture? +* How large should the difference be between an untouched login pack and a fully upgraded daily pack? +* How do weekly habits such as church attendance affect the pack? +* Are there separate weekly/monthly packs even though we are moving away from streaks? +* How quickly should one person be able to complete the base compendium? + +### Rarity / Trade-Ups + +* Exact rarity probabilities. +* Exact expected supply assuming perfect habit completion. +* How set-specific rarity works. +* How many lower-rarity cards are required for a trade-up. +* Whether finish/material/printing affect trade-up outputs. +* What happens to destroyed-card provenance. + +### Wear + +* Exact condition bands. +* Lowest acceptable visual condition. +* Whether higher rarity cards have condition floors. +* How heavily wear should affect collectability/value. + +### Trinity Card + +* Final name. +* John 3:16 or another verse. +* Artwork that communicates the idea without bad or irreverent depiction. +* How customizable finishes/materials should look. + +### Christian Content + +* Who ultimately reviews doctrinal/content accuracy? +* How deep tradition-specific collections go. +* Exact taxonomy for controversies / non-Nicene movements. +* How citations/sources are stored and displayed on cards. + +### Rendering + +* Three.js vs Flutter Scene technical spike. +* Exact web framework. +* Shader/material design. +* Asset-generation workflow with Blender + AI tooling. +* Static thumbnail generation from the same card contract. + +### Social / Trading + +* When trading belongs in the roadmap. +* Whether population numbers are public by default. +* How offers work. +* Whether destroyed cards remain visible in historical collection records. +* What parts of a user's binder/profile are private versus public. + +### Privacy + +* Exact client-side encryption model. +* Whether BYOK is practical for normal users. +* Account recovery when encryption keys are lost. +* What anonymous/statistical data can be collected without weakening the privacy model. +* Exact accountability-sharing permissions. + diff --git a/image-1.png b/image-1.png new file mode 100644 index 0000000..38aa28e Binary files /dev/null and b/image-1.png differ diff --git a/image-2.png b/image-2.png new file mode 100644 index 0000000..a6e3b95 Binary files /dev/null and b/image-2.png differ diff --git a/image-3.png b/image-3.png new file mode 100644 index 0000000..9eb7cd0 Binary files /dev/null and b/image-3.png differ diff --git a/image-4.png b/image-4.png new file mode 100644 index 0000000..18a7ae1 Binary files /dev/null and b/image-4.png differ diff --git a/image.png b/image.png new file mode 100644 index 0000000..8122e96 Binary files /dev/null and b/image.png differ diff --git a/sanctification-tcg/art-direction.md b/sanctification-tcg/art-direction.md new file mode 100644 index 0000000..4867b15 --- /dev/null +++ b/sanctification-tcg/art-direction.md @@ -0,0 +1,845 @@ +# Sanctification TCG - Art Direction + +## Goal + +I want the cards and the product as a whole to have a very distinct visual identity. + +The current direction I like most is: + +> **Stained-glass-inspired as the main style, with tapestry and illuminated manuscript influence as supporting texture and ornament.** + +The overall goal should be: + +> **Sacred, beautiful, and stylized. Not a dense museum reconstruction.** + +I do not want the cards to feel like a generic fantasy TCG, casino game, anime card game, or hyper-realistic historical reenactment. + +The art should feel reverent and Christian first, while still being visually strong enough that collecting the cards feels exciting. + +--- + +## Core Visual Principles + +Across the whole product, the artwork should generally follow these ideas: + +- Sacred and reverent tone. +- Stained-glass-inspired composition. +- Jewel-tone palette where appropriate. +- Gold can be used as an accent, but should become more prominent as rarity increases. +- Tapestry, linen, parchment, and illuminated-manuscript textures can support the overall look. +- Clear subject silhouettes. +- Cards should remain readable at thumbnail size. +- Avoid hyper-photorealism. +- Avoid anime or comic-book energy. +- Avoid a dark fantasy aesthetic. +- Avoid visual language that feels like gambling, loot boxes, or a casino. +- Avoid overly dense historical reconstruction. +- Prefer symbolic clarity over excessive detail. +- Every card should feel like it belongs to the same product, even when different sets or traditions have their own visual flavor. + +A good way to think about the stained-glass direction is that the cards should feel inspired by sacred windows and devotional art without needing to literally reproduce any specific historical style. + +--- + +## Product-Wide Visual Language + +The card art should influence the rest of the app. + +Possible shared UI motifs: + +- Cathedral-window or arched shapes. +- Restrained ornamental dividers. +- Deep blue, burgundy, cream, green, and gold accents. +- Parchment, stone, linen, or subtle glass-inspired surfaces. +- Illuminated-manuscript-style decorative elements used sparingly. +- Soft lighting rather than neon effects. +- Ornament should support hierarchy rather than fill every empty space. + +The application UI should still feel modern and usable. + +The goal is not to make the entire interface look medieval. + +The historical / sacred art direction should mostly come through: + +- Cards. +- Pack openings. +- Binder / collection presentation. +- Headers and section treatments. +- Decorative accents. +- Important moments and milestones. + +Normal application controls should remain clear and familiar. + +--- + +# Card Art + +## General Composition + +Cards should generally prioritize: + +1. Subject. +2. Identity. +3. Short supporting content. +4. Ornamentation. + +The art should be readable even when the card is relatively small. + +The card should not require the user to zoom in just to understand who or what it represents. + +For character cards, prefer: + +- One clear subject. +- Strong silhouette. +- Limited background complexity. +- Symbolic items associated with the subject. +- Calm, iconic poses over excessive action scenes unless the card itself represents an event. + +For event cards, larger compositions and multiple figures can be used when necessary. + +For locations, items, doctrines, councils, and other categories, the framing can change while still staying within the same overall stained-glass visual language. + +--- + +## Text on the Card + +The physical card should stay concise. + +The app itself can provide the deeper educational content when the card is inspected. + +A normal card can contain: + +- Name. +- Short subtitle or descriptor. +- Short verse, quote, or historical line. +- Reference. + +Example: + +> **David** +> Shepherd King +> +> “The LORD is my shepherd...” +> Psalm 23:1 + +The front content priority should generally be: + +1. Scripture directly related to the card. +2. Historically significant Christian quotation. +3. Concise historical or theological statement. + +We should not force a full verse onto the card when it harms the visual design. + +A short excerpt with an ellipsis is fine when it is clearly intentional. + +The full content can appear in the inspection panel. + +--- + +## Card Back + +The card back should generally be standardized so it does not reveal the identity of a card during pack opening. + +The back should not leak: + +- Card identity. +- Exact rarity. +- Printing. +- Finish. +- Material. +- Other traits that would spoil the reveal. + +It can use the Sanctification branding and common visual language. + +Different sets could potentially have different backs in the future, but only if this does not meaningfully spoil the card reveal. + +--- + +# Card Inspection + +The card itself should remain relatively clean. + +More detailed information belongs in the inspection experience. + +When inspecting a card, the user should be able to: + +- Rotate the card freely. +- Flip between front and back. +- Zoom in to inspect artwork and physical characteristics. +- Observe material properties responding dynamically to lighting. +- Observe wear and imperfections where applicable. +- Return quickly to the originating collection / binder context. + +The inspection UI should also expose the educational and collectible information that does not need to live directly on the physical card. + +Possible sections: + +## About + +- Full verse or quote. +- Description. +- Historical context. +- Theological context. +- Related Scripture. +- Related events / people / cards. +- Learn-more sources. + +## Card + +- Rarity. +- Set. +- Printing. +- Finish. +- Material. +- Wear classification. +- Exact wear percentage. + +## Provenance + +- Original owner. +- Date opened. +- Population. +- Serial / instance information. + +--- + +## Card Dimensions and Resolution + +### Aspect Ratio + +All standard cards should use a **5:7 portrait aspect ratio**. + +This corresponds cleanly with the current physical prototype dimensions: + +- Width: 63 mm +- Height: 88.2 mm + +Artwork, card frames, masks, material maps, and generated assets should +all use the same 5:7 coordinate space unless a specific experiment +requires otherwise. + +### Master Artwork Resolution + +The standard master artwork resolution should be: + +**3000 × 4200 px** + +This is the source-quality asset and should be retained even if smaller +versions are generated for the application. + +All associated masks should use the same dimensions and alignment where +practical, including: + +- Foil masks +- Holographic masks +- Metallic masks +- Roughness maps +- Wear masks +- Normal/bump maps +- Printing masks + +### Runtime Resolutions + +Runtime assets may be generated from the master depending on context: + +- High-resolution inspection: 2000 × 2800 +- Standard card rendering: 1000 × 1400 +- Binder / collection thumbnails: 500 × 700 or smaller + +Exact runtime sizes should eventually be determined through performance +testing, particularly on mobile devices. + +The master artwork should never be replaced by a lower-resolution +runtime export. + +--- + +# Rarity Art Direction + +Rarity should have a major impact on the visual grammar of the card. + +A common card should not look almost as impressive as a legendary card. + +Rarity should primarily affect: + +- Frame complexity. +- Number of ornamental elements. +- Visual storytelling. +- Palette richness. +- Composition complexity. +- Symbolic detail. +- Amount of gold. +- Background complexity. + +Rarity should **not** simply mean adding more glow or visual effects. + +A useful principle: + +> **Higher rarity should add storytelling and ornamentation, not merely more effects.** + +Another way to think about it: + +> **Common cards should feel like a simple parish stained-glass window. Legendary cards should feel closer to the centerpiece window behind the altar.** + +--- + +## Common + +Common cards should be intentionally restrained. + +### Border + +- Thin and simple. +- Geometric. +- Very little decorative ornament. +- No elaborate corner medallions. +- No complex illuminated-manuscript scrollwork. +- No gold. + +### Color + +- Limited palette. +- Roughly 2-3 dominant colors plus cream / parchment. +- More muted or restrained jewel tones. +- Gold used mostly for separation or small accents. + +### Artwork + +- It doesn't need to be just the subject but rather them in the background doing something +- Simple background. +- Larger stained-glass panes. +- Fewer small glass segments. +- Minimal symbolic elements. +- Calm composition. +- No unnecessary visual drama. +- Minimal micro-detail. + +### Overall Feel + +- Humble. +- Clean. +- Grounded. +- Accessible. +- Still beautiful, but obviously not premium. + +Example: + +**Timothy** + +- Timothy holding a scroll. +- Simple ancient street or landscape. +- Simple arch. +- Blue / green / cream palette. +- Thin brass or gold divider. +- No ornate botanical frame. +- No special corner symbols. +- No heavily gilded title plaque. + +![alt text](image-2.png) + +--- + +## Uncommon + +Uncommon cards can begin introducing more refinement. + +### Border + +- Slightly thicker frame. +- Small decorative accents. +- Limited corner ornament. +- Some leaf or manuscript-inspired motifs. + +### Color + +- Slightly broader palette. +- More saturated jewel tones. +- Small use of gold. + +### Artwork + +- More detailed backgrounds. +- Additional symbolic element. +- Slightly more complex stained-glass segmentation. +- More deliberate framing around the subject. + +### Overall Feel + +- Refined. +- Noticeably more interesting than Common. +- Still not highly ornate. + +--- + + +## Rare + +Rare cards should feel distinctive. + +### Border + +- Decorative frame. +- More custom ornament. +- Symbolic motifs related to the subject. + +### Color + +- Richer jewel-tone palette. +- More contrast. +- More use of gold. + +### Artwork + +- Stronger visual storytelling. +- More complex stained-glass structures. +- Multiple meaningful background elements. +- More deliberate lighting composition. + +### Overall Feel + +- Distinctive. +- Collectible. +- Clearly above the normal card experience. + +--- + +## Extraordinary + +Extraordinary cards should feel prestigious. + +### Border + +- Elaborate framing. +- Strong illuminated-manuscript influence. +- More bespoke ornamentation. +- Larger use of gold. + +### Color + +- Deep and saturated jewel tones. +- Broad palette while remaining cohesive. +- Strong visual contrast. + +### Artwork + +- Complex composition. +- Multiple symbolic elements. +- Richer environments. +- More detailed stained-glass segmentation. +- Greater sense of scale or drama. + +### Overall Feel + +- Prestigious. +- Elaborate. +- Immediately special. + +--- + +## Legendary + +Legendary cards should be visually unmistakable. + +They can break some of the normal card-layout rules while still clearly belonging to the same product. + +### Border + +- Highly bespoke. +- Custom symbolic elements related specifically to the card. +- Extensive but tasteful ornamentation. +- Rich gilding. + +### Color + +- Full jewel-tone palette where appropriate. +- Strong gold accents. +- Deep color contrast. + +### Artwork + +- Strong visual storytelling. +- Multiple symbols connected directly to the subject. +- Complex stained-glass composition. +- More dramatic light. +- More detailed environments. +- Unique framing or iconography. + +### Overall Feel + +- Immediately special. +- Reverent. +- Iconic. +- Worth stopping to inspect. + +Example: + +**David** + +Possible visual elements: + +- Crown. +- Harp. +- Shepherd staff. +- Sheep. +- Jerusalem. +- Star / celestial imagery. +- Bespoke gold framing. +- Rich red and blue garments. +- Complex stained-glass composition. + +These elements help tell David's story rather than simply adding decoration for decoration's sake. + +![alt text](image-3.png) + + +#### Alt Style - Tapestry + +![alt text](image-4.png) + +--- + +# Rarity vs. Physical Treatment + +Rarity, material, and finish are separate systems. + +This is important. + +A Common holographic card should still visually read as a Common card. + +A Legendary plain-paper card should still visually read as Legendary. + +## Rarity controls + +- Graphic design. +- Frame. +- Composition. +- Symbolic storytelling. +- Base art complexity. +- Ornamentation. + +## Material controls + +- The physical substrate of the card. +- Surface microtexture and roughness. +- Edge and core appearance. +- Base reflectivity and perceived weight. +- Paper, linen, plastic, metal, or wood response. + +## Finish controls + +- A treatment applied over the material and printed artwork. +- Selective foil reflection. +- Holographic spectral response. +- Coating strength and coverage. + +For example: + +- Common Paper + Printed Ink Timothy. +- Common Paper + Foil Timothy. +- Common Linen + Holographic Timothy. +- Common Metal + Printed Ink Timothy. + +All of them should still use the same underlying Common Timothy card design. + +Material and finish should not be treated as an unrestricted Cartesian product. A combination should exist because it has an intentional physical and visual interpretation, not merely because both options are available independently. + +--- + +# Material and Finish Direction + +If a collectible property exists, the player should ideally be able to perceive it visually or interactively. + +Examples: + +- Finish -> visible. +- Material -> visible. +- Wear -> visible. +- Imperfections -> visible. +- Printing -> visible. +- Weight -> indirectly observable. +- Provenance -> inspectable metadata. +- Population -> inspectable metadata. + +The material is the card's substrate. The finish is a treatment applied over that substrate. They should remain independently identifiable when the card moves under light. + +--- + +## Paper + +- Matte with fine, irregular fibers. +- Broad, weak reflection rather than a sharp sheen. +- Grain should become visible during close inspection without making the artwork noisy. + +## Plastic + +- Smoother surface. +- Clear-coated dielectric reflection. +- A bounded moving highlight should remain visible even with Printed Ink. +- Cleaner edges. + +## Linen + +- Raised interlaced warp and weft rather than generic noise. +- Soft diffuse reflection. +- Small shaded recesses should help the weave read under moving light. +- Particularly compatible with tapestry-inspired artwork. + +## Metal + +- Reflective underprint and a visibly metallic edge. +- Darker artwork values create deeper engraved grooves; the maximum depth should remain low enough that the card still feels printed and collectible rather than sculpted. +- Groove depth should read through surface normals, restrained cavity shading, and fine bevel highlights when the card moves. +- Printed artwork colors must remain recognizable rather than becoming uniformly silver or gold. +- Visually heavier. +- Should still preserve artwork readability. + +## Wood + +- Warm substrate tint with a darker brown core. +- Longitudinal, slightly warped grain with shallow physical relief. +- Grain should support the card artwork rather than overpowering it. +- Reflection should remain restrained and less polished than plastic or metal. + +## Foil + +- Selective angle-dependent metallic reflection controlled by an art-directed mask. +- Light should sweep visibly across treated regions without washing out the entire illustration. +- Foil should remain distinguishable from a Metal substrate: foil is a localized surface treatment, while metal changes the whole card body and edge. +- Metal + Foil should preserve the printed colors and read as restrained, slightly raised champagne-metal polishing over etched steel. It should not turn the whole illustration into opaque gold. + +## Holographic + +- View-dependent spectral response. +- Holographic response should use masks so intentional portions of the artwork react. +- Should remain tasteful and avoid a neon / fantasy appearance. +- Spectral color can be clearly visible, but extreme white sheen at grazing angles should remain controlled. + +## Finish Mask Direction + +- Finish masks should be authored and reviewed as part of the card art rather than treated as arbitrary technical output. +- Preserve titles, verses, and other critical text unless a specific treatment is intentionally designed for them. +- Do not create obvious circular or elliptical exclusions around faces, hands, animals, or other subjects. Those shapes read as artificial holes when the card moves. +- Avoid isolated mask islands that produce unrelated holographic spots across skin or focal features. +- Prefer coverage that follows stained-glass panes, ornament, borders, garments, environmental color, and other compositional regions. +- Evaluate every mask while rotating the card under multiple lighting angles. A mask that looks acceptable in a still image may look distracting in motion. + +--- + +# Wear + +Wear should not make cards ugly to the point that the artwork becomes undesirable. + +Even the lowest wear levels should remain visually readable and collectible. + +A 0.00 wear card can be noticeably scratched, worn, or damaged while still looking like a cared-for physical collectible rather than a destroyed object. + +Possible classifications are still subject to change, but could look something like: + +- Mint. +- Near Mint. +- Minimal Wear. +- Moderately Played. +- Worn. + +Wear can affect: + +- Edge whitening. +- Surface scratches. +- Small scuffs. +- Minor print abrasion. +- Subtle corner wear. + +It should not normally obscure: + +- Subject identity. +- Important text. +- Major portions of the artwork. + +Wear patterns should ideally be seeded per card instance so two cards with the same wear value do not necessarily have identical damage. + +--- + +# Printing Variants + +Printing should affect the graphic presentation of the same card identity. + +## Normal + +- Standard frame. +- Name. +- Subtitle. +- Short verse / quote / fact. +- Normal artwork area. + +## Borderless + +- Artwork extends farther toward the card edges. +- Reduced frame. +- Minimal text treatment. + +## Textless + +- Artwork-focused. +- Little or no text. +- Educational information remains available in the inspection panel. + +## Boundless + +- Full-bleed or near-full-bleed artwork. +- Minimal or absent conventional frame. +- Intended to feel like a special printing rather than a higher rarity. + +Printing type should not change the underlying rarity of the card. + +--- + +# Thumbnail Readability + +All normal card designs should remain recognizable in a binder or collection grid. + +At thumbnail size, the user should ideally still be able to identify: + +- The subject or general composition. +- The card rarity through its frame language. +- Major color identity. +- Whether the card has an unusual printing or finish when practical. + +Fine text does not need to remain readable at thumbnail size. + +The card name should become readable as the card grows larger or when selected. + +--- + +# Pack Opening Art Direction + +Pack opening should create anticipation without feeling like a casino. + +Rarity can affect reveal pacing and presentation. + +Possible progression: + +## Common + +- Simple lift. +- Quick flip. +- Minimal lighting change. + +## Uncommon + +- Slight pause. +- Mild environmental emphasis. + +## Rare + +- More deliberate reveal. +- Stronger lighting transition. +- Small audio / visual flourish. + +## Extraordinary + +- More dramatic reveal timing. +- Larger environmental shift. +- More pronounced presentation. + +## Legendary + +- Unique or near-unique reveal treatment. +- Strong visual emphasis. +- Longer anticipation, but still skippable. + +The goal is: + +> **Rarity can affect anticipation without going full casino.** + +All reveal animations should remain skippable. + +--- + +# Things to Avoid + +Across all rarities: + +- Hyper-photorealistic character portraits. +- Anime styling. +- Comic-book styling. +- Neon fantasy effects. +- Generic fantasy RPG framing. +- Excessive glowing particles. +- Overly busy ornament. +- Illegible typography. +- Decorative detail that makes the subject harder to identify. +- Making every card look premium. +- Using rarity only as a glow / particle intensity slider. +- Turning historical Christian art into parody. +- Making Common cards deliberately ugly. + +The distinction should be: + +> **Common is simple. Legendary is elaborate.** + +Not: + +> **Common is bad. Legendary is good.** + +--- + +# MVP Art Goals + +For the MVP, I do not need every card to be a full-scale art project. + +The interesting part is proving that the entire visual system works. + +The MVP should establish: + +- A consistent stained-glass-inspired card style. +- A clearly readable rarity system. +- A reusable card layout system. +- At least one working example of each rarity. +- Multiple materials. +- Multiple finishes. +- Multiple printing types. +- Wear. +- 3D card inspection. +- Light-reactive materials. +- Pack-opening presentation. +- Thumbnail / binder representations. + +I think it makes more sense to create a smaller number of highly intentional art examples and then build the other cards from the same visual system rather than independently designing all 50 cards. + +--- + +# Open Art Questions + +Things that still need to be explored: + +- Exact card dimensions / aspect ratio. +- Final typography. +- Exact color palette. +- Whether each rarity has a fixed base frame or a family of frames. +- Whether card categories have their own secondary visual motifs. +- Whether tradition-specific cards alter UI or border accents. +- Exact visual treatment for controversies. +- Whether halos should be used based on historical iconography, card category, tradition, or purely artistic composition. +- How much motion should exist inside holographic cards. +- Exact distinction between Extraordinary and Legendary. +- Whether higher-rarity cards are always visually more complex or occasionally gain prestige through extreme simplicity. +- How much the card artwork itself changes across printing variants. +- Final card back design. + +--- + +# Current Direction Summary + +The current visual north star is: + +> **Stained glass first. Tapestry and illuminated manuscript second. Sacred and stylized throughout.** + +Rarity should be obvious through design language before the user reads a label. + +Common cards should be restrained and humble. + +Legendary cards should feel bespoke and narratively rich. + +Finishes and materials should enhance the physical card without replacing rarity as the primary graphic hierarchy. + +The cards should be beautiful enough to want to collect, but the overall product should always remain reverent rather than flashy. diff --git a/sanctification-tcg/blender_prototype/README.md b/sanctification-tcg/blender_prototype/README.md new file mode 100644 index 0000000..1342a17 --- /dev/null +++ b/sanctification-tcg/blender_prototype/README.md @@ -0,0 +1,75 @@ +# Sanctification TCG — Blender card material spike + +Open `sanctification_card_material_prototype.blend` in Blender 5.2+. + +The file uses one shared physical mesh (`GEO_Card_Master_63x88`) across every card instance. Each object overrides three material slots: front, universal back, and edge/core. The source art remains separate from geometry. + +## Main organization + +- `Cards/`: one collection per finish/substrate variant. +- `Scene_Setup/`: neutral studio, labels, four lights, and inspection cameras. +- `NG_CardSurfaceTexture`: preserves and lightly grades printed art. +- `NG_CardMicroNormal`: paper fiber, restrained linen weave, and scratch response. +- `NG_CardWear`: seeded surface/edge wear mask. +- `NG_CardFoil`: art-masked, view-responsive foil enhancement. +- `NG_CardHolographic`: broad, angle-dependent spectral shift with art-derived masking. +- `NG_CardBase`: shared Principled BSDF assembly. + +The holographic animation is keyed on `CARD_Holo_Demo` from frames 1–120 at 24 fps. + +### Premium finish revision (v3) + +The three David finish materials now use a printed-ink BSDF mixed with a masked metallic reflection. Foil retains the artwork color in both layers: the old gold Screen blend, diffuse rainbow mix, thin-film color outlines, and high-frequency groove bump are absent from these materials. Holo colors the reflective layer using broad UV gradients and the signed view direction in card coordinates. It is an artistic diffraction approximation, not a spectral optical simulation. + +`NG_David_PrintProtection` combines saturation/ink selection with soft UV exclusions for the face, hand, lamb, and central title panel. Those exclusions are specific to `legendary.png`; another artwork needs adjusted regions or an authored mask. + +Edit `Finish Strength` on each premium material (foil 0.56, holo 0.60, metal experiment 0.72), the reflective BSDF Roughness (foil 0.19, holo 0.23), and `Broad diffraction spectrum` in the holo material. `CardRenderConfig.*` properties are descriptive snapshots; they do not drive shader values. `premium_finishes.py` owns these defaults and can update the existing scene without rebuilding geometry or the wear shader. The full builder calls it automatically. + +Use `render_premium_review.py` in Blender to regenerate the review stills; set `REVIEW_ANIMATION=True` in its execution globals for the 120 PNG animation frames. The MP4 is encoded externally from `renders/holo_frames/holo_%04d.png` at 24 fps. Preview files with `material_revision` or `lighting_preview` in their names are historical experiments; the standard filenames are the current deliverables. + +## Runtime translation + +Reasonable bake/export candidates: + +- Base color +- Roughness and metallic maps +- Paper/linen micro-normal +- Static wear and imperfection masks + +Likely custom runtime shader work: + +- View-dependent foil +- Holographic diffraction/spectral shift +- Angle response and moving reflection behavior + +The Blender node setup is a look-development reference; it is not expected to survive glTF export unchanged. + +## Delivered renders + +- `comparison_lineup.png` +- `timothy_matte_closeup.png` +- `timothy_linen_closeup.png` +- `david_paper_closeup.png` +- `david_foil_closeup.png` +- `david_holographic_head_on.png` +- `david_holographic_spectrum_angle.png` +- `card_three_quarter_thickness.png` +- `wear_test_timothy.png` +- `universal_card_back.png` +- `holographic_rotation_demo.mp4` (5 seconds, 120 frames, 24 fps) + +The animation is authored on the hidden `CARD_Holo_Demo` linked instance, not the lineup card. Its final range is −18° to +12°: wide enough to show the changing surface response while avoiding a strip-light angle that made the printed title unreadable. Blender in this environment did not expose FFmpeg output internally, so Blender rendered the PNG sequence and the included MP4 was encoded from those exact frames with H.264. + +The studio uses 3 W key, 1 W fill, 0.65 W strip, and 1.4 W rim lights, a 0.012-strength world, and −1.35 stop AgX exposure. The narrower strip is positioned at (0.10, 0.012, 0.23) m to catch the demo's tilt range. These settings are shared by the saved scene, review stills, and animation. + +## Spike observations + +- Paper, linen, and laminate differ mainly through roughness, coat, and micro-normal; the art texture does not change. +- Linen remains intentionally close-inspection detail. Increasing `Linen Strength` beyond the current setting begins to compete with the watercolor/stained-glass texture. +- Foil is most convincing when the saturated/value-derived art mask controls the metallic lift instead of applying reflection uniformly. +- Holo needs a runtime view vector plus broad directional bands. Static glTF export can carry its base PBR state, but not the authored spectral shift. +- `David_MetalHolo` deliberately marks the excessive end of the range and is not a recommended product target. + +## Rebuild + +The scene was constructed through Blender MCP using `build_sanctification_card_prototype.py`. Running the script again intentionally rebuilds the current scene from scratch. diff --git a/sanctification-tcg/blender_prototype/build_sanctification_card_prototype.py b/sanctification-tcg/blender_prototype/build_sanctification_card_prototype.py new file mode 100644 index 0000000..2ec08d7 --- /dev/null +++ b/sanctification-tcg/blender_prototype/build_sanctification_card_prototype.py @@ -0,0 +1,593 @@ +import bpy +import math +import os +from mathutils import Vector + + +ROOT = "/home/dkzver/dev/sanctification-tcg" +OUT = os.path.join(ROOT, "blender_prototype", "renders") +BLEND = os.path.join(ROOT, "blender_prototype", "sanctification_card_material_prototype.blend") +COMMON = os.path.join(ROOT, "common.png") +LEGENDARY = os.path.join(ROOT, "legendary.png") +BACK = os.path.join(ROOT, "card-back.png") + +os.makedirs(OUT, exist_ok=True) + + +def clear_scene(): + if bpy.context.object and bpy.context.object.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for block in (bpy.data.collections, bpy.data.materials, bpy.data.meshes, + bpy.data.curves, bpy.data.cameras, bpy.data.lights, + bpy.data.node_groups): + for item in list(block): + if item.users == 0: + block.remove(item) + + +def collection(name, parent=None): + c = bpy.data.collections.new(name) + (parent or bpy.context.scene.collection).children.link(c) + return c + + +def link_only(obj, col): + for c in list(obj.users_collection): + c.objects.unlink(obj) + col.objects.link(obj) + + +def sock(node, name, fallback=None): + s = node.inputs.get(name) + if s is None and fallback is not None: + s = node.inputs[fallback] + return s + + +def new_socket(group, name, in_out, socket_type, default=None, min_value=None, max_value=None): + s = group.interface.new_socket(name=name, in_out=in_out, socket_type=socket_type) + if default is not None: + s.default_value = default + if min_value is not None: + s.min_value = min_value + if max_value is not None: + s.max_value = max_value + return s + + +def make_surface_texture_group(): + g = bpy.data.node_groups.new("NG_CardSurfaceTexture", "ShaderNodeTree") + new_socket(g, "Art Color", "INPUT", "NodeSocketColor", (0.8, 0.8, 0.8, 1)) + new_socket(g, "Saturation", "INPUT", "NodeSocketFloat", 1.0, 0.0, 2.0) + new_socket(g, "Value", "INPUT", "NodeSocketFloat", 1.0, 0.0, 2.0) + new_socket(g, "Color", "OUTPUT", "NodeSocketColor") + n_in = g.nodes.new("NodeGroupInput") + n_out = g.nodes.new("NodeGroupOutput") + hsv = g.nodes.new("ShaderNodeHueSaturation") + hsv.inputs[0].default_value = 0.5 + hsv.inputs[3].default_value = 1.0 + g.links.new(n_in.outputs["Art Color"], hsv.inputs[4]) + g.links.new(n_in.outputs["Saturation"], hsv.inputs[1]) + g.links.new(n_in.outputs["Value"], hsv.inputs[2]) + g.links.new(hsv.outputs[0], n_out.inputs["Color"]) + n_in.location = (-260, 0) + hsv.location = (0, 0) + n_out.location = (220, 0) + return g + + +def make_wear_group(): + g = bpy.data.node_groups.new("NG_CardWear", "ShaderNodeTree") + new_socket(g, "Base Color", "INPUT", "NodeSocketColor", (0.8, 0.8, 0.8, 1)) + new_socket(g, "Vector", "INPUT", "NodeSocketVector") + new_socket(g, "Wear Amount", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Edge Wear Amount", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Seed", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1000.0) + new_socket(g, "Color", "OUTPUT", "NodeSocketColor") + new_socket(g, "Wear Mask", "OUTPUT", "NodeSocketFloat") + + ni = g.nodes.new("NodeGroupInput") + no = g.nodes.new("NodeGroupOutput") + sep = g.nodes.new("ShaderNodeSeparateXYZ") + subx = g.nodes.new("ShaderNodeMath"); subx.operation = "SUBTRACT"; subx.inputs[1].default_value = 0.5 + suby = g.nodes.new("ShaderNodeMath"); suby.operation = "SUBTRACT"; suby.inputs[1].default_value = 0.5 + absx = g.nodes.new("ShaderNodeMath"); absx.operation = "ABSOLUTE" + absy = g.nodes.new("ShaderNodeMath"); absy.operation = "ABSOLUTE" + maximum = g.nodes.new("ShaderNodeMath"); maximum.operation = "MAXIMUM" + edge = g.nodes.new("ShaderNodeMapRange") + edge.inputs[1].default_value = 0.435 + edge.inputs[2].default_value = 0.5 + edge.inputs[3].default_value = 0.0 + edge.inputs[4].default_value = 1.0 + edge.clamp = True + combine = g.nodes.new("ShaderNodeCombineXYZ") + seed_scale = g.nodes.new("ShaderNodeMath"); seed_scale.operation = "MULTIPLY"; seed_scale.inputs[1].default_value = 0.137 + addvec = g.nodes.new("ShaderNodeVectorMath"); addvec.operation = "ADD" + noise = g.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 22.0 + noise.inputs["Detail"].default_value = 5.0 + noise.inputs["Roughness"].default_value = 0.72 + mul_edge_noise = g.nodes.new("ShaderNodeMath"); mul_edge_noise.operation = "MULTIPLY" + mul_edge_amt = g.nodes.new("ShaderNodeMath"); mul_edge_amt.operation = "MULTIPLY" + fine_noise = g.nodes.new("ShaderNodeTexNoise") + fine_noise.inputs["Scale"].default_value = 135.0 + fine_noise.inputs["Detail"].default_value = 2.0 + fine_ramp = g.nodes.new("ShaderNodeValToRGB") + fine_ramp.color_ramp.elements[0].position = 0.61 + fine_ramp.color_ramp.elements[1].position = 0.73 + mul_surface = g.nodes.new("ShaderNodeMath"); mul_surface.operation = "MULTIPLY" + add_masks = g.nodes.new("ShaderNodeMath"); add_masks.operation = "ADD"; add_masks.use_clamp = True + mix = g.nodes.new("ShaderNodeMixRGB") + mix.blend_type = "MIX" + mix.inputs[2].default_value = (0.72, 0.68, 0.57, 1) + + g.links.new(ni.outputs["Vector"], sep.inputs[0]) + g.links.new(sep.outputs[0], subx.inputs[0]); g.links.new(subx.outputs[0], absx.inputs[0]) + g.links.new(sep.outputs[1], suby.inputs[0]); g.links.new(suby.outputs[0], absy.inputs[0]) + g.links.new(absx.outputs[0], maximum.inputs[0]); g.links.new(absy.outputs[0], maximum.inputs[1]) + g.links.new(maximum.outputs[0], edge.inputs[0]) + g.links.new(ni.outputs["Seed"], seed_scale.inputs[0]) + g.links.new(seed_scale.outputs[0], combine.inputs[0]); g.links.new(ni.outputs["Seed"], combine.inputs[1]) + g.links.new(ni.outputs["Vector"], addvec.inputs[0]); g.links.new(combine.outputs[0], addvec.inputs[1]) + g.links.new(addvec.outputs[0], noise.inputs["Vector"]); g.links.new(addvec.outputs[0], fine_noise.inputs["Vector"]) + g.links.new(edge.outputs[0], mul_edge_noise.inputs[0]); g.links.new(noise.outputs["Fac"], mul_edge_noise.inputs[1]) + g.links.new(mul_edge_noise.outputs[0], mul_edge_amt.inputs[0]); g.links.new(ni.outputs["Edge Wear Amount"], mul_edge_amt.inputs[1]) + g.links.new(fine_noise.outputs["Fac"], fine_ramp.inputs[0]) + g.links.new(fine_ramp.outputs[0], mul_surface.inputs[0]); g.links.new(ni.outputs["Wear Amount"], mul_surface.inputs[1]) + g.links.new(mul_edge_amt.outputs[0], add_masks.inputs[0]); g.links.new(mul_surface.outputs[0], add_masks.inputs[1]) + g.links.new(add_masks.outputs[0], mix.inputs[0]); g.links.new(ni.outputs["Base Color"], mix.inputs[1]) + g.links.new(mix.outputs[0], no.inputs["Color"]); g.links.new(add_masks.outputs[0], no.inputs["Wear Mask"]) + for i, n in enumerate(g.nodes): + n.location = ((i % 6) * 180 - 520, -(i // 6) * 180) + return g + + +def make_micro_group(): + g = bpy.data.node_groups.new("NG_CardMicroNormal", "ShaderNodeTree") + new_socket(g, "Vector", "INPUT", "NodeSocketVector") + new_socket(g, "Micro Texture Strength", "INPUT", "NodeSocketFloat", 0.08, 0.0, 1.0) + new_socket(g, "Linen Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Holo Groove Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Scratch Amount", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Seed", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1000.0) + new_socket(g, "Normal", "OUTPUT", "NodeSocketVector") + ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput") + noise = g.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 470.0 + noise.inputs["Detail"].default_value = 2.0 + noise.inputs["Roughness"].default_value = 0.62 + wave_x = g.nodes.new("ShaderNodeTexWave"); wave_x.wave_type = "BANDS"; wave_x.bands_direction = "X" + wave_y = g.nodes.new("ShaderNodeTexWave"); wave_y.wave_type = "BANDS"; wave_y.bands_direction = "Y" + for w in (wave_x, wave_y): + w.inputs["Scale"].default_value = 190.0 + w.inputs["Distortion"].default_value = 0.8 + w.inputs["Detail"].default_value = 2.0 + weave = g.nodes.new("ShaderNodeMath"); weave.operation = "ADD" + weave_amt = g.nodes.new("ShaderNodeMath"); weave_amt.operation = "MULTIPLY" + paper_amt = g.nodes.new("ShaderNodeMath"); paper_amt.operation = "MULTIPLY" + add = g.nodes.new("ShaderNodeMath"); add.operation = "ADD" + scratch_wave = g.nodes.new("ShaderNodeTexWave"); scratch_wave.wave_type = "BANDS"; scratch_wave.bands_direction = "X" + scratch_wave.inputs["Scale"].default_value = 1250.0 + scratch_wave.inputs["Distortion"].default_value = 8.0 + scratch_ramp = g.nodes.new("ShaderNodeValToRGB") + scratch_ramp.color_ramp.elements[0].position = 0.48 + scratch_ramp.color_ramp.elements[1].position = 0.505 + scratch_amt = g.nodes.new("ShaderNodeMath"); scratch_amt.operation = "MULTIPLY" + add2 = g.nodes.new("ShaderNodeMath"); add2.operation = "ADD" + groove_wave = g.nodes.new("ShaderNodeTexWave"); groove_wave.wave_type = "BANDS"; groove_wave.bands_direction = "X" + groove_wave.inputs["Scale"].default_value = 900.0 + groove_wave.inputs["Distortion"].default_value = 1.8 + groove_wave.inputs["Detail"].default_value = 2.0 + groove_amt = g.nodes.new("ShaderNodeMath"); groove_amt.operation = "MULTIPLY" + add3 = g.nodes.new("ShaderNodeMath"); add3.operation = "ADD" + bump = g.nodes.new("ShaderNodeBump") + bump.inputs["Distance"].default_value = 0.00015 + bump.inputs["Strength"].default_value = 0.42 + g.links.new(ni.outputs["Vector"], noise.inputs["Vector"]) + g.links.new(ni.outputs["Vector"], wave_x.inputs["Vector"]); g.links.new(ni.outputs["Vector"], wave_y.inputs["Vector"]) + g.links.new(wave_x.outputs["Color"], weave.inputs[0]); g.links.new(wave_y.outputs["Color"], weave.inputs[1]) + g.links.new(weave.outputs[0], weave_amt.inputs[0]); g.links.new(ni.outputs["Linen Strength"], weave_amt.inputs[1]) + g.links.new(noise.outputs["Fac"], paper_amt.inputs[0]); g.links.new(ni.outputs["Micro Texture Strength"], paper_amt.inputs[1]) + g.links.new(weave_amt.outputs[0], add.inputs[0]); g.links.new(paper_amt.outputs[0], add.inputs[1]) + g.links.new(ni.outputs["Vector"], scratch_wave.inputs["Vector"]); g.links.new(scratch_wave.outputs["Color"], scratch_ramp.inputs[0]) + g.links.new(scratch_ramp.outputs[0], scratch_amt.inputs[0]); g.links.new(ni.outputs["Scratch Amount"], scratch_amt.inputs[1]) + g.links.new(add.outputs[0], add2.inputs[0]); g.links.new(scratch_amt.outputs[0], add2.inputs[1]) + g.links.new(ni.outputs["Vector"], groove_wave.inputs["Vector"]); g.links.new(groove_wave.outputs["Color"], groove_amt.inputs[0]); g.links.new(ni.outputs["Holo Groove Strength"], groove_amt.inputs[1]) + g.links.new(add2.outputs[0], add3.inputs[0]); g.links.new(groove_amt.outputs[0], add3.inputs[1]) + g.links.new(add3.outputs[0], bump.inputs["Height"]); g.links.new(bump.outputs["Normal"], no.inputs["Normal"]) + for i, n in enumerate(g.nodes): n.location = ((i % 5) * 190 - 450, -(i // 5) * 190) + return g + + +def make_foil_group(): + g = bpy.data.node_groups.new("NG_CardFoil", "ShaderNodeTree") + new_socket(g, "Base Color", "INPUT", "NodeSocketColor") + new_socket(g, "Art Mask", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Base Metallic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Base Roughness", "INPUT", "NodeSocketFloat", 0.4, 0.0, 1.0) + new_socket(g, "Foil Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Foil Roughness", "INPUT", "NodeSocketFloat", 0.22, 0.0, 1.0) + new_socket(g, "Color", "OUTPUT", "NodeSocketColor") + new_socket(g, "Metallic", "OUTPUT", "NodeSocketFloat") + new_socket(g, "Roughness", "OUTPUT", "NodeSocketFloat") + ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput") + lw = g.nodes.new("ShaderNodeLayerWeight") + inv = g.nodes.new("ShaderNodeMath"); inv.operation = "SUBTRACT"; inv.inputs[0].default_value = 1.0 + angle = g.nodes.new("ShaderNodeMath"); angle.operation = "MULTIPLY_ADD"; angle.inputs[1].default_value = 0.65; angle.inputs[2].default_value = 0.18 + mask = g.nodes.new("ShaderNodeMath"); mask.operation = "MULTIPLY" + amt = g.nodes.new("ShaderNodeMath"); amt.operation = "MULTIPLY" + static_amt = g.nodes.new("ShaderNodeMath"); static_amt.operation = "MULTIPLY" + mix = g.nodes.new("ShaderNodeMixRGB"); mix.blend_type = "SCREEN"; mix.inputs[2].default_value = (0.74, 0.67, 0.48, 1) + metboost = g.nodes.new("ShaderNodeMath"); metboost.operation = "MULTIPLY"; metboost.inputs[1].default_value = 0.72 + metadd = g.nodes.new("ShaderNodeMath"); metadd.operation = "ADD"; metadd.use_clamp = True + roughmix = g.nodes.new("ShaderNodeMix") + roughmix.data_type = "FLOAT" + g.links.new(lw.outputs["Facing"], inv.inputs[1]); g.links.new(lw.outputs["Facing"], angle.inputs[0]) + g.links.new(ni.outputs["Art Mask"], mask.inputs[0]); g.links.new(angle.outputs[0], mask.inputs[1]) + g.links.new(mask.outputs[0], amt.inputs[0]); g.links.new(ni.outputs["Foil Strength"], amt.inputs[1]) + g.links.new(ni.outputs["Art Mask"], static_amt.inputs[0]); g.links.new(ni.outputs["Foil Strength"], static_amt.inputs[1]) + g.links.new(amt.outputs[0], mix.inputs[0]); g.links.new(ni.outputs["Base Color"], mix.inputs[1]) + g.links.new(static_amt.outputs[0], metboost.inputs[0]); g.links.new(metboost.outputs[0], metadd.inputs[0]); g.links.new(ni.outputs["Base Metallic"], metadd.inputs[1]) + g.links.new(static_amt.outputs[0], roughmix.inputs[0]); g.links.new(ni.outputs["Base Roughness"], roughmix.inputs[2]); g.links.new(ni.outputs["Foil Roughness"], roughmix.inputs[3]) + g.links.new(mix.outputs[0], no.inputs["Color"]); g.links.new(metadd.outputs[0], no.inputs["Metallic"]); g.links.new(roughmix.outputs[0], no.inputs["Roughness"]) + for i, n in enumerate(g.nodes): n.location = ((i % 5) * 190 - 420, -(i // 5) * 190) + return g + + +def make_holo_group(): + g = bpy.data.node_groups.new("NG_CardHolographic", "ShaderNodeTree") + new_socket(g, "Base Color", "INPUT", "NodeSocketColor") + new_socket(g, "Vector", "INPUT", "NodeSocketVector") + new_socket(g, "Art Mask", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Base Metallic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Base Roughness", "INPUT", "NodeSocketFloat", 0.4, 0.0, 1.0) + new_socket(g, "Holo Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.5) + new_socket(g, "Holo Scale", "INPUT", "NodeSocketFloat", 5.0, 0.5, 30.0) + new_socket(g, "Holo Saturation", "INPUT", "NodeSocketFloat", 0.72, 0.0, 1.5) + new_socket(g, "Holo Angle Response", "INPUT", "NodeSocketFloat", 0.85, 0.0, 2.0) + new_socket(g, "Color", "OUTPUT", "NodeSocketColor") + new_socket(g, "Metallic", "OUTPUT", "NodeSocketFloat") + new_socket(g, "Roughness", "OUTPUT", "NodeSocketFloat") + new_socket(g, "Thin Film Thickness", "OUTPUT", "NodeSocketFloat") + ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput") + lw = g.nodes.new("ShaderNodeLayerWeight") + inv = g.nodes.new("ShaderNodeMath"); inv.operation = "SUBTRACT"; inv.inputs[0].default_value = 1.0 + power = g.nodes.new("ShaderNodeMath"); power.operation = "POWER"; power.inputs[1].default_value = 0.38 + angle = g.nodes.new("ShaderNodeMath"); angle.operation = "MULTIPLY" + anglebase = g.nodes.new("ShaderNodeMath"); anglebase.operation = "ADD"; anglebase.inputs[1].default_value = 0.035 + wave = g.nodes.new("ShaderNodeTexWave"); wave.wave_type = "BANDS"; wave.bands_direction = "X" + wave.inputs["Distortion"].default_value = 2.2; wave.inputs["Detail"].default_value = 3.0 + rotate = g.nodes.new("ShaderNodeVectorRotate"); rotate.rotation_type = "AXIS_ANGLE"; rotate.inputs["Axis"].default_value = (0,0,1); rotate.inputs["Angle"].default_value = math.radians(33) + wave2 = g.nodes.new("ShaderNodeTexWave"); wave2.wave_type = "BANDS"; wave2.bands_direction = "X"; wave2.inputs["Distortion"].default_value = 1.1; wave2.inputs["Detail"].default_value = 2.0 + scale2 = g.nodes.new("ShaderNodeMath"); scale2.operation = "MULTIPLY"; scale2.inputs[1].default_value = 0.63 + wave1_amt = g.nodes.new("ShaderNodeMath"); wave1_amt.operation = "MULTIPLY"; wave1_amt.inputs[1].default_value = 0.72 + wave2_amt = g.nodes.new("ShaderNodeMath"); wave2_amt.operation = "MULTIPLY"; wave2_amt.inputs[1].default_value = 0.28 + waves_add = g.nodes.new("ShaderNodeMath"); waves_add.operation = "ADD" + noise = g.nodes.new("ShaderNodeTexNoise"); noise.inputs["Scale"].default_value = 3.2; noise.inputs["Detail"].default_value = 2.0 + nscale = g.nodes.new("ShaderNodeMath"); nscale.operation = "MULTIPLY"; nscale.inputs[1].default_value = 0.16 + fscale = g.nodes.new("ShaderNodeMath"); fscale.operation = "MULTIPLY"; fscale.inputs[1].default_value = 0.72 + add1 = g.nodes.new("ShaderNodeMath"); add1.operation = "ADD" + add2 = g.nodes.new("ShaderNodeMath"); add2.operation = "ADD" + fract = g.nodes.new("ShaderNodeMath"); fract.operation = "FRACT" + ramp = g.nodes.new("ShaderNodeValToRGB") + cr = ramp.color_ramp + cr.interpolation = "EASE" + while len(cr.elements) > 2: cr.elements.remove(cr.elements[-1]) + cr.elements[0].position = 0.0; cr.elements[0].color = (1.0, 0.34, 0.28, 1) + cr.elements[1].position = 1.0; cr.elements[1].color = (1.0, 0.34, 0.28, 1) + for pos, color in [ + (0.17, (1.0, 0.76, 0.24, 1)), (0.34, (0.28, 0.95, 0.58, 1)), + (0.51, (0.20, 0.78, 1.0, 1)), (0.68, (0.38, 0.48, 1.0, 1)), + (0.85, (0.95, 0.32, 0.82, 1))]: + e = cr.elements.new(pos); e.color = color + hsv = g.nodes.new("ShaderNodeHueSaturation"); hsv.inputs[0].default_value = 0.5; hsv.inputs[2].default_value = 1.0; hsv.inputs[3].default_value = 1.0 + holoamt = g.nodes.new("ShaderNodeMath"); holoamt.operation = "MULTIPLY" + holoamt2 = g.nodes.new("ShaderNodeMath"); holoamt2.operation = "MULTIPLY" + holo_cap = g.nodes.new("ShaderNodeMath"); holo_cap.operation = "MINIMUM"; holo_cap.inputs[1].default_value = 0.62 + mix = g.nodes.new("ShaderNodeMixRGB"); mix.blend_type = "MIX" + metboost = g.nodes.new("ShaderNodeMath"); metboost.operation = "MULTIPLY"; metboost.inputs[1].default_value = 0.42 + metadd = g.nodes.new("ShaderNodeMath"); metadd.operation = "ADD"; metadd.use_clamp = True + rough_target = g.nodes.new("ShaderNodeMath"); rough_target.operation = "MULTIPLY"; rough_target.inputs[1].default_value = 0.62 + roughmix = g.nodes.new("ShaderNodeMix"); roughmix.data_type = "FLOAT" + film_mask = g.nodes.new("ShaderNodeMath"); film_mask.operation = "MULTIPLY" + film_scale = g.nodes.new("ShaderNodeMath"); film_scale.operation = "MULTIPLY"; film_scale.inputs[1].default_value = 460.0 + g.links.new(lw.outputs["Facing"], inv.inputs[1]); g.links.new(lw.outputs["Facing"], power.inputs[0]); g.links.new(power.outputs[0], angle.inputs[0]); g.links.new(ni.outputs["Holo Angle Response"], angle.inputs[1]); g.links.new(angle.outputs[0], anglebase.inputs[0]) + g.links.new(ni.outputs["Vector"], wave.inputs["Vector"]); g.links.new(ni.outputs["Holo Scale"], wave.inputs["Scale"]) + g.links.new(ni.outputs["Vector"], rotate.inputs["Vector"]); g.links.new(rotate.outputs["Vector"], wave2.inputs["Vector"]); g.links.new(ni.outputs["Holo Scale"], scale2.inputs[0]); g.links.new(scale2.outputs[0], wave2.inputs["Scale"]) + g.links.new(wave.outputs["Fac"], wave1_amt.inputs[0]); g.links.new(wave2.outputs["Fac"], wave2_amt.inputs[0]); g.links.new(wave1_amt.outputs[0], waves_add.inputs[0]); g.links.new(wave2_amt.outputs[0], waves_add.inputs[1]) + g.links.new(ni.outputs["Vector"], noise.inputs["Vector"]); g.links.new(noise.outputs["Fac"], nscale.inputs[0]); g.links.new(lw.outputs["Facing"], fscale.inputs[0]) + g.links.new(waves_add.outputs[0], add1.inputs[0]); g.links.new(nscale.outputs[0], add1.inputs[1]); g.links.new(add1.outputs[0], add2.inputs[0]); g.links.new(fscale.outputs[0], add2.inputs[1]); g.links.new(add2.outputs[0], fract.inputs[0]); g.links.new(fract.outputs[0], ramp.inputs[0]); g.links.new(ramp.outputs[0], hsv.inputs[4]); g.links.new(ni.outputs["Holo Saturation"], hsv.inputs[1]) + g.links.new(ni.outputs["Art Mask"], holoamt.inputs[0]); g.links.new(anglebase.outputs[0], holoamt.inputs[1]); g.links.new(holoamt.outputs[0], holoamt2.inputs[0]); g.links.new(ni.outputs["Holo Strength"], holoamt2.inputs[1]) + g.links.new(holoamt2.outputs[0], holo_cap.inputs[0]); g.links.new(holo_cap.outputs[0], mix.inputs[0]); g.links.new(ni.outputs["Base Color"], mix.inputs[1]); g.links.new(hsv.outputs[0], mix.inputs[2]) + g.links.new(holo_cap.outputs[0], metboost.inputs[0]); g.links.new(metboost.outputs[0], metadd.inputs[0]); g.links.new(ni.outputs["Base Metallic"], metadd.inputs[1]) + g.links.new(ni.outputs["Base Roughness"], rough_target.inputs[0]); g.links.new(holo_cap.outputs[0], roughmix.inputs[0]); g.links.new(ni.outputs["Base Roughness"], roughmix.inputs[2]); g.links.new(rough_target.outputs[0], roughmix.inputs[3]) + g.links.new(ni.outputs["Art Mask"], film_mask.inputs[0]); g.links.new(ni.outputs["Holo Strength"], film_mask.inputs[1]); g.links.new(film_mask.outputs[0], film_scale.inputs[0]) + g.links.new(mix.outputs[0], no.inputs["Color"]); g.links.new(metadd.outputs[0], no.inputs["Metallic"]); g.links.new(roughmix.outputs[0], no.inputs["Roughness"]); g.links.new(film_scale.outputs[0], no.inputs["Thin Film Thickness"]) + for i, n in enumerate(g.nodes): n.location = ((i % 6) * 190 - 520, -(i // 6) * 190) + return g + + +def make_base_group(): + g = bpy.data.node_groups.new("NG_CardBase", "ShaderNodeTree") + new_socket(g, "Base Color", "INPUT", "NodeSocketColor") + new_socket(g, "Roughness", "INPUT", "NodeSocketFloat", 0.45, 0.0, 1.0) + new_socket(g, "Metallic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Coat Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Coat Roughness", "INPUT", "NodeSocketFloat", 0.18, 0.0, 1.0) + new_socket(g, "Anisotropic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Sheen Weight", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0) + new_socket(g, "Thin Film Thickness", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1000.0) + new_socket(g, "Thin Film IOR", "INPUT", "NodeSocketFloat", 1.38, 1.0, 3.0) + new_socket(g, "Normal", "INPUT", "NodeSocketVector") + new_socket(g, "Shader", "OUTPUT", "NodeSocketShader") + ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput") + bsdf = g.nodes.new("ShaderNodeBsdfPrincipled") + g.links.new(ni.outputs["Base Color"], sock(bsdf, "Base Color")) + g.links.new(ni.outputs["Roughness"], sock(bsdf, "Roughness")) + g.links.new(ni.outputs["Metallic"], sock(bsdf, "Metallic")) + g.links.new(ni.outputs["Coat Strength"], sock(bsdf, "Coat Weight", 18)) + g.links.new(ni.outputs["Coat Roughness"], sock(bsdf, "Coat Roughness", 19)) + g.links.new(ni.outputs["Anisotropic"], bsdf.inputs["Anisotropic"]) + g.links.new(ni.outputs["Sheen Weight"], bsdf.inputs["Sheen Weight"]) + g.links.new(ni.outputs["Thin Film Thickness"], bsdf.inputs["Thin Film Thickness"]) + g.links.new(ni.outputs["Thin Film IOR"], bsdf.inputs["Thin Film IOR"]) + g.links.new(ni.outputs["Normal"], sock(bsdf, "Normal")) + g.links.new(bsdf.outputs["BSDF"], no.inputs["Shader"]) + ni.location = (-260, 0); bsdf.location = (0, 0); no.location = (260, 0) + return g + + +def load_image(path, name): + img = bpy.data.images.load(path, check_existing=True) + img.name = name + img.colorspace_settings.name = "sRGB" + return img + + +def create_front_material(name, image, cfg, groups): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + mat.diffuse_color = (0.35, 0.3, 0.24, 1) + nt = mat.node_tree; nt.nodes.clear() + out = nt.nodes.new("ShaderNodeOutputMaterial"); out.location = (1050, 0) + texcoord = nt.nodes.new("ShaderNodeTexCoord"); texcoord.location = (-1100, -180) + tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = image; tex.interpolation = "Linear"; tex.location = (-1100, 180); tex.label = image.name + surf = nt.nodes.new("ShaderNodeGroup"); surf.node_tree = groups["surface"]; surf.location = (-820, 180) + wear = nt.nodes.new("ShaderNodeGroup"); wear.node_tree = groups["wear"]; wear.location = (-570, 180) + sep = nt.nodes.new("ShaderNodeSeparateColor"); sep.mode = "HSV"; sep.location = (-810, -80) + maskmul = nt.nodes.new("ShaderNodeMath"); maskmul.operation = "MULTIPLY"; maskmul.location = (-575, -80) + maskramp = nt.nodes.new("ShaderNodeValToRGB"); maskramp.location = (-390, -80) + maskramp.color_ramp.elements[0].position = 0.20 + maskramp.color_ramp.elements[1].position = 0.56 + foil = nt.nodes.new("ShaderNodeGroup"); foil.node_tree = groups["foil"]; foil.location = (-105, 170) + holo = nt.nodes.new("ShaderNodeGroup"); holo.node_tree = groups["holo"]; holo.location = (170, 170) + micro = nt.nodes.new("ShaderNodeGroup"); micro.node_tree = groups["micro"]; micro.location = (170, -240) + base = nt.nodes.new("ShaderNodeGroup"); base.node_tree = groups["base"]; base.location = (640, 80) + surf.inputs["Saturation"].default_value = cfg.get("saturation", 1.0) + surf.inputs["Value"].default_value = cfg.get("value", 1.0) + wear.inputs["Wear Amount"].default_value = cfg.get("wear", 0.0) + wear.inputs["Edge Wear Amount"].default_value = cfg.get("edge_wear", 0.0) + wear.inputs["Seed"].default_value = cfg.get("seed", 1.0) + foil.inputs["Base Metallic"].default_value = cfg.get("metallic", 0.0) + foil.inputs["Base Roughness"].default_value = cfg.get("roughness", 0.5) + foil.inputs["Foil Strength"].default_value = cfg.get("foil", 0.0) + foil.inputs["Foil Roughness"].default_value = cfg.get("foil_roughness", 0.22) + holo.inputs["Holo Strength"].default_value = cfg.get("holo", 0.0) + holo.inputs["Holo Scale"].default_value = cfg.get("holo_scale", 5.0) + holo.inputs["Holo Saturation"].default_value = cfg.get("holo_saturation", 0.72) + holo.inputs["Holo Angle Response"].default_value = cfg.get("holo_angle", 0.85) + micro.inputs["Micro Texture Strength"].default_value = cfg.get("micro", 0.06) + micro.inputs["Linen Strength"].default_value = cfg.get("linen", 0.0) + micro.inputs["Holo Groove Strength"].default_value = cfg.get("holo_groove", 0.0) + micro.inputs["Scratch Amount"].default_value = cfg.get("scratches", 0.0) + micro.inputs["Seed"].default_value = cfg.get("seed", 1.0) + base.inputs["Coat Strength"].default_value = cfg.get("coat", 0.0) + base.inputs["Coat Roughness"].default_value = cfg.get("coat_roughness", 0.2) + base.inputs["Anisotropic"].default_value = cfg.get("anisotropic", 0.0) + base.inputs["Sheen Weight"].default_value = cfg.get("sheen", 0.0) + base.inputs["Thin Film IOR"].default_value = cfg.get("thin_film_ior", 1.38) + nt.links.new(texcoord.outputs["UV"], tex.inputs["Vector"]); nt.links.new(tex.outputs["Color"], surf.inputs["Art Color"]) + nt.links.new(surf.outputs["Color"], wear.inputs["Base Color"]); nt.links.new(texcoord.outputs["Generated"], wear.inputs["Vector"]) + nt.links.new(surf.outputs["Color"], sep.inputs["Color"]); nt.links.new(sep.outputs[1], maskmul.inputs[0]); nt.links.new(sep.outputs[2], maskmul.inputs[1]); nt.links.new(maskmul.outputs[0], maskramp.inputs[0]) + nt.links.new(wear.outputs["Color"], foil.inputs["Base Color"]); nt.links.new(maskramp.outputs["Color"], foil.inputs["Art Mask"]) + nt.links.new(foil.outputs["Color"], holo.inputs["Base Color"]); nt.links.new(texcoord.outputs["Generated"], holo.inputs["Vector"]); nt.links.new(maskramp.outputs["Color"], holo.inputs["Art Mask"]); nt.links.new(foil.outputs["Metallic"], holo.inputs["Base Metallic"]); nt.links.new(foil.outputs["Roughness"], holo.inputs["Base Roughness"]) + nt.links.new(texcoord.outputs["Generated"], micro.inputs["Vector"]) + nt.links.new(holo.outputs["Color"], base.inputs["Base Color"]); nt.links.new(holo.outputs["Metallic"], base.inputs["Metallic"]); nt.links.new(holo.outputs["Roughness"], base.inputs["Roughness"]); nt.links.new(holo.outputs["Thin Film Thickness"], base.inputs["Thin Film Thickness"]); nt.links.new(micro.outputs["Normal"], base.inputs["Normal"]); nt.links.new(base.outputs["Shader"], out.inputs["Surface"]) + for k, v in cfg.items(): + if isinstance(v, (int, float, str, bool)): + mat["CardRenderConfig." + k] = v + mat["CardRenderConfig.frontTexture"] = image.filepath + mat["CardRenderConfig.backTexture"] = BACK + return mat + + +def create_simple_texture_material(name, image, roughness, coat, groups): + return create_front_material(name, image, dict(roughness=roughness, coat=coat, micro=0.04, seed=11), groups) + + +def create_edge_material(name, color, roughness, metallic=0.0): + m = bpy.data.materials.new(name); m.use_nodes = True + p = m.node_tree.nodes.get("Principled BSDF") + p.inputs["Base Color"].default_value = (*color, 1) + p.inputs["Roughness"].default_value = roughness + p.inputs["Metallic"].default_value = metallic + return m + + +def rounded_card_mesh(): + w, h, t, r, seg = 0.063, 0.0882, 0.00040, 0.0028, 8 + pts = [] + for cx, cy, a0 in [ + (w/2-r, h/2-r, 0), (-w/2+r, h/2-r, math.pi/2), + (-w/2+r, -h/2+r, math.pi), (w/2-r, -h/2+r, 3*math.pi/2)]: + for i in range(seg + 1): + a = a0 + (math.pi/2) * i / seg + pts.append((cx + r*math.cos(a), cy + r*math.sin(a))) + n = len(pts) + verts = [(x,y,-t/2) for x,y in pts] + [(x,y,t/2) for x,y in pts] + faces = [list(reversed(range(n))), list(range(n, 2*n))] + for i in range(n): + j = (i+1) % n + faces.append([i, j, n+j, n+i]) + mesh = bpy.data.meshes.new("GEO_Card_Master_63x88") + mesh.from_pydata(verts, [], faces); mesh.update() + mesh.materials.append(None); mesh.materials.append(None); mesh.materials.append(None) + mesh.polygons[0].material_index = 1 + mesh.polygons[1].material_index = 0 + for p in mesh.polygons[2:]: p.material_index = 2 + uv = mesh.uv_layers.new(name="UV_Card_Artwork") + for p in mesh.polygons: + for li in p.loop_indices: + vi = mesh.loops[li].vertex_index + x,y,z = mesh.vertices[vi].co + if p.index == 0: + uv.data[li].uv = (0.5-x/w, y/h+0.5) + elif p.index == 1: + uv.data[li].uv = (x/w+0.5, y/h+0.5) + else: + uv.data[li].uv = ((vi % n)/n, 0 if vi < n else 1) + return mesh + + +def add_card(name, mesh, col, front_mat, back_mat, edge_mat, loc, rot): + obj = bpy.data.objects.new(name, mesh) + col.objects.link(obj) + obj.location = loc; obj.rotation_euler = rot + for idx, mat in enumerate((front_mat, back_mat, edge_mat)): + slot = obj.material_slots[idx] + slot.link = "OBJECT"; slot.material = mat + bev = obj.modifiers.new("MOD_EdgeHighlight_Bevel", "BEVEL") + bev.width = 0.00028; bev.segments = 2; bev.limit_method = "ANGLE" + obj["CardRenderConfig.frontMaterial"] = front_mat.name + obj["CardRenderConfig.backMaterial"] = back_mat.name + obj["Physical.Width_mm"] = 63.0; obj["Physical.Height_mm"] = 88.2; obj["Physical.Thickness_mm"] = 0.4; obj["Physical.CornerRadius_mm"] = 2.8 + return obj + + +def look_at(obj, target): + obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler() + + +def add_area(name, loc, energy, size, color, target, col, shape="DISK", size_y=None): + d = bpy.data.lights.new(name, "AREA"); d.energy = energy; d.shape = shape; d.size = size; d.color = color + if size_y is not None: d.size_y = size_y + o = bpy.data.objects.new(name, d); col.objects.link(o); o.location = loc; look_at(o, target); return o + + +def add_camera(name, loc, target, col, ortho=None, lens=55): + d = bpy.data.cameras.new(name); d.lens = lens + if ortho is not None: d.type = "ORTHO"; d.ortho_scale = ortho + o = bpy.data.objects.new(name, d); col.objects.link(o); o.location = loc; look_at(o, target); return o + + +def make_label(text, loc, col, mat): + curve = bpy.data.curves.new("TXT_" + text.replace(" ", "_"), "FONT") + curve.body = text; curve.align_x = "CENTER"; curve.align_y = "CENTER"; curve.size = 0.006; curve.extrude = 0.00002 + obj = bpy.data.objects.new("LABEL_" + text.replace(" ", "_"), curve); col.objects.link(obj); obj.location = loc; obj.data.materials.append(mat); return obj + + +clear_scene() +scene = bpy.context.scene +scene.unit_settings.system = "METRIC"; scene.unit_settings.length_unit = "MILLIMETERS"; scene.unit_settings.scale_length = 1.0 +scene.render.engine = "BLENDER_EEVEE" +scene.render.resolution_x = 1280; scene.render.resolution_y = 900; scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = "PNG" +scene.render.film_transparent = False +scene.render.image_settings.color_mode = "RGBA" +scene.render.image_settings.color_depth = "8" +scene.render.fps = 24; scene.frame_start = 1; scene.frame_end = 120 +scene.view_settings.look = "AgX - Medium High Contrast" +scene.view_settings.exposure = -1.35 +scene.world.color = (0.008, 0.009, 0.012) +world = scene.world; world.use_nodes = True +world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.010, 0.014, 1) +world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.012 + +cards_root = collection("Cards") +setup = collection("Scene_Setup") +lights_col = collection("Lights", setup); cams_col = collection("Cameras", setup); labels_col = collection("Labels", setup); env_col = collection("Environment", setup) + +groups = {"surface": make_surface_texture_group(), "wear": make_wear_group(), "micro": make_micro_group(), "foil": make_foil_group(), "holo": make_holo_group(), "base": make_base_group()} +img_common = load_image(COMMON, "TEX_Timothy_Front") +img_legendary = load_image(LEGENDARY, "TEX_David_Front") +img_back = load_image(BACK, "TEX_Card_Back") + +cfgs = { + "Timothy_Matte": (img_common, dict(substrate="paper", finish="matte", roughness=0.61, metallic=0.0, coat=0.02, coat_roughness=0.36, micro=0.08, linen=0.0, sheen=0.0, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=13)), + "Timothy_Linen": (img_common, dict(substrate="linen", finish="matte", roughness=0.77, metallic=0.0, coat=0.0, micro=0.045, linen=0.38, sheen=0.16, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=23)), + "Timothy_Gloss": (img_common, dict(substrate="plastic_laminate", finish="gloss", roughness=0.29, metallic=0.0, coat=0.48, coat_roughness=0.13, micro=0.015, linen=0.0, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=31)), + "David_Paper": (img_legendary, dict(substrate="premium_paper", finish="satin", roughness=0.47, metallic=0.0, coat=0.14, coat_roughness=0.24, micro=0.055, linen=0.0, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=41)), + "David_Foil": (img_legendary, dict(substrate="foil_laminate", finish="selective_foil", roughness=0.24, metallic=0.04, coat=0.44, coat_roughness=0.09, anisotropic=0.55, micro=0.018, linen=0.0, foil=0.90, foil_roughness=0.075, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=53)), + "David_Holo": (img_legendary, dict(substrate="premium_cardstock", finish="layered_holographic", roughness=0.17, metallic=0.02, coat=0.80, coat_roughness=0.055, anisotropic=0.68, micro=0.012, linen=0.0, holo_groove=0.035, foil=0.22, foil_roughness=0.085, holo=1.20, holo_scale=3.7, holo_saturation=0.82, holo_angle=1.48, thin_film_ior=1.42, wear=0.0, edge_wear=0.0, scratches=0.0, seed=67)), + "David_MetalHolo": (img_legendary, dict(substrate="metal", finish="layered_holographic", roughness=0.13, metallic=0.66, coat=0.78, coat_roughness=0.045, anisotropic=0.80, micro=0.008, linen=0.0, holo_groove=0.06, foil=0.78, foil_roughness=0.055, holo=1.45, holo_scale=4.8, holo_saturation=1.05, holo_angle=1.40, thin_film_ior=1.48, wear=0.0, edge_wear=0.0, scratches=0.0, seed=79)), + "Wear_Test": (img_common, dict(substrate="paper", finish="matte_worn", roughness=0.67, metallic=0.0, coat=0.01, coat_roughness=0.4, micro=0.10, linen=0.0, foil=0.0, holo=0.0, wear=0.11, edge_wear=0.60, scratches=0.14, seed=137)), +} +materials = {k: create_front_material("MAT_" + k, img, cfg, groups) for k, (img, cfg) in cfgs.items()} +back_mat = create_simple_texture_material("MAT_Card_Back_Universal", img_back, 0.44, 0.12, groups) +edge_paper = create_edge_material("MAT_Edge_PaperCore", (0.55, 0.48, 0.36), 0.72) +edge_plastic = create_edge_material("MAT_Edge_Plastic", (0.20, 0.22, 0.23), 0.30) +edge_metal = create_edge_material("MAT_Edge_Metal", (0.32, 0.24, 0.10), 0.18, 0.78) + +mesh = rounded_card_mesh() +layout = { + "Timothy_Matte": ((-0.082, 0.061, 0), (math.radians(-2), math.radians(6), math.radians(-1.5))), + "Timothy_Linen": ((0.0, 0.061, 0), (math.radians(2), math.radians(-4), 0)), + "Timothy_Gloss": ((0.082, 0.061, 0), (math.radians(-3), math.radians(8), math.radians(1.5))), + "David_Paper": ((-0.123, -0.061, 0), (math.radians(2), math.radians(-6), math.radians(-1.5))), + "David_Foil": ((-0.041, -0.061, 0), (math.radians(-2), math.radians(8), math.radians(0.5))), + "David_Holo": ((0.041, -0.061, 0), (math.radians(3), math.radians(-11), math.radians(-0.5))), + "David_MetalHolo": ((0.123, -0.061, 0), (math.radians(-4), math.radians(13), math.radians(1.5))), + "Wear_Test": ((0.0, -0.185, 0), (0, 0, 0)), +} +objects = {} +for key, (loc, rot) in layout.items(): + col = collection(key, cards_root) + edge = edge_metal if key == "David_MetalHolo" else edge_plastic if key == "Timothy_Gloss" else edge_paper + objects[key] = add_card("CARD_" + key, mesh, col, materials[key], back_mat, edge, loc, rot) +objects["Wear_Test"].hide_render = True +holo_demo_col = collection("Holo_Demo", cards_root) +holo_demo = add_card("CARD_Holo_Demo", mesh, holo_demo_col, materials["David_Holo"], back_mat, edge_paper, (0,0,0), (0,0,0)) +holo_demo.hide_render = True + +label_mat = bpy.data.materials.new("MAT_Label_White"); label_mat.use_nodes = True +lp = label_mat.node_tree.nodes.get("Principled BSDF"); lp.inputs["Base Color"].default_value = (0.72,0.75,0.80,1); lp.inputs["Roughness"].default_value = 0.65; lp.inputs["Emission Color"].default_value = (0.035,0.04,0.05,1); lp.inputs["Emission Strength"].default_value = 0.25 +for key in ["Timothy_Matte", "Timothy_Linen", "Timothy_Gloss", "David_Paper", "David_Foil", "David_Holo", "David_MetalHolo"]: + x,y,_ = layout[key][0] + make_label(key.replace("_", " "), (x, y-0.051, 0.0015), labels_col, label_mat) + +bg_mat = bpy.data.materials.new("MAT_Studio_Backdrop"); bg_mat.use_nodes = True +bp = bg_mat.node_tree.nodes.get("Principled BSDF"); bp.inputs["Base Color"].default_value = (0.012,0.015,0.022,1); bp.inputs["Roughness"].default_value = 0.82 +bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0,0,-0.012)) +backdrop = bpy.context.object; backdrop.name = "Studio_Backdrop"; link_only(backdrop, env_col); backdrop.data.materials.append(bg_mat) + +add_area("LIGHT_Key_Softbox", (-0.20,0.19,0.27), 3.0, 0.16, (1.0,0.92,0.82), (0,0,0), lights_col, "DISK") +add_area("LIGHT_Fill_Soft", (0.18,-0.14,0.22), 1.0, 0.20, (0.80,0.88,1.0), (0,0,0), lights_col, "DISK") +add_area("LIGHT_Specular_Strip", (0.24,0.10,0.16), 3.0, 0.018, (1.0,0.98,0.94), (0.025,-0.02,0), lights_col, "RECTANGLE", 0.23) +add_area("LIGHT_Top_Rim", (0.0,0.31,0.12), 1.4, 0.035, (0.90,0.94,1.0), (0,0.03,0), lights_col, "RECTANGLE", 0.20) + +cam_comp = add_camera("CAM_Comparison", (0,0,0.48), (0,0,0), cams_col, ortho=0.335) +cam_front = add_camera("CAM_Front_Inspection", (0,0,0.28), (0,0,0), cams_col, ortho=0.108) +cam_three = add_camera("CAM_ThreeQuarter", (0.14,-0.040,0.28), (0,0,0), cams_col, lens=66) +cam_holo_angle = add_camera("CAM_Holo_Angle", (0.105,-0.025,0.27), (0,0,0), cams_col, lens=72) +scene.camera = cam_comp + +# Holographic demo keys live on a dedicated linked instance, leaving the lineup +# card deterministic for head-on material comparisons. +holo_obj = holo_demo +holo_obj.rotation_euler = (math.radians(-6), math.radians(-18), 0); holo_obj.keyframe_insert("rotation_euler", frame=1) +holo_obj.rotation_euler = (math.radians(5), math.radians(12), math.radians(1.5)); holo_obj.keyframe_insert("rotation_euler", frame=120) +# Blender 5 uses layered Actions; the default Bezier interpolation is intentionally +# retained so the inspection motion eases naturally at each end. +scene.frame_set(1) + +scene["README_RuntimeTranslation"] = "Bake/export: base color, roughness, metallic, micro-normal, static wear/imperfection masks. Runtime shader: view-dependent foil, holographic diffraction, angle-based spectrum, dynamic reflection." +scene["README_PrototypeIntent"] = "Rarity remains artwork/framing; finish remains independent physical light response. Shared mesh GEO_Card_Master_63x88 and shared NG_* node groups support parameterized variants." +scene["CardRenderConfig_Schema"] = "frontTexture, backTexture, substrate, finish, roughness, metallic, foil, holo, wear, seed" + +import runpy +runpy.run_path(os.path.join(ROOT, 'blender_prototype', 'premium_finishes.py'))['apply_premium_finishes']() +runpy.run_path(os.path.join(ROOT, 'blender_prototype', 'timothy_linen_foil.py'))['add_timothy_linen_foil']() +bpy.ops.wm.save_as_mainfile(filepath=BLEND) +result = {"status": "built", "blend": BLEND, "objects": list(objects.keys()), "materials": list(materials.keys()), "node_groups": list(groups.keys()), "output_dir": OUT} diff --git a/sanctification-tcg/blender_prototype/card_finish_material.py b/sanctification-tcg/blender_prototype/card_finish_material.py new file mode 100644 index 0000000..82e3e74 --- /dev/null +++ b/sanctification-tcg/blender_prototype/card_finish_material.py @@ -0,0 +1,156 @@ +"""Artwork-independent foil/holo materials. No portrait or layout coordinates. + +Call create_card_finish_material with a bpy Image and an optional grayscale mask. +Mask values: 0 = printed ink only, 1 = maximum requested finish coverage. +The default automatic mask is a color heuristic, not semantic segmentation. +""" +import bpy + + +def socket(g, name, kind, direction='INPUT', default=None): + s=g.interface.new_socket(name=name,in_out=direction,socket_type=kind) + if default is not None:s.default_value=default + return s + + +def node(t, kind, name): + n=t.nodes.new(kind);n.name=n.label=name + return n + + +def op(t, operation, a, b=0, name=None): + n=node(t,'ShaderNodeMath',name or operation);n.operation=operation + for i,v in enumerate((a,b)): + if isinstance(v,(int,float)):n.inputs[i].default_value=v + else:t.links.new(v,n.inputs[i]) + return n.outputs[0] + + +def smooth(t,value,lo,hi,name): + n=node(t,'ShaderNodeMapRange',name);n.clamp=True;n.interpolation_type='SMOOTHSTEP' + n.inputs['From Min'].default_value=lo;n.inputs['From Max'].default_value=hi + t.links.new(value,n.inputs['Value']);return n.outputs[0] + + +def auto_mask_group(): + name='NG_CardFinish_AutomaticMask' + if name in bpy.data.node_groups:return bpy.data.node_groups[name] + g=bpy.data.node_groups.new(name,'ShaderNodeTree') + socket(g,'Artwork','NodeSocketColor') + socket(g,'Mask','NodeSocketFloat','OUTPUT') + i=node(g,'NodeGroupInput','Artwork');o=node(g,'NodeGroupOutput','Color based coverage') + hsv=node(g,'ShaderNodeSeparateColor','Artwork saturation and value');hsv.mode='HSV' + g.links.new(i.outputs[0],hsv.inputs[0]) + saturation=smooth(g,hsv.outputs[1],.08,.58,'Favor saturated color') + value=smooth(g,hsv.outputs[2],.015,.16,'Reduce finish over dark ink') + g.links.new(op(g,'MULTIPLY',saturation,value),o.inputs[0]) + for index,n in enumerate(g.nodes):n.location=(index*200,0) + return g + + +def finish_group(): + name='NG_CardFinish_ReflectiveCoating' + if name in bpy.data.node_groups:return bpy.data.node_groups[name] + g=bpy.data.node_groups.new(name,'ShaderNodeTree') + for nm,kind,default in [('Artwork','NodeSocketColor',None),('UV','NodeSocketVector',None), + ('Normal','NodeSocketVector',None),('Coverage','NodeSocketFloat',1.0), + ('Finish Strength','NodeSocketFloat',.56),('Finish Roughness','NodeSocketFloat',.19), + ('Ink Roughness','NodeSocketFloat',.36),('Anisotropy','NodeSocketFloat',.32), + ('Holographic','NodeSocketFloat',0.0),('Sheen','NodeSocketFloat',0.0)]: + socket(g,nm,kind,default=default) + socket(g,'Shader','NodeSocketShader','OUTPUT') + i=node(g,'NodeGroupInput','CardRenderConfig');o=node(g,'NodeGroupOutput','Card surface') + ink=node(g,'ShaderNodeBsdfPrincipled','Printed ink') + g.links.new(i.outputs['Artwork'],ink.inputs['Base Color']);g.links.new(i.outputs['Normal'],ink.inputs['Normal']) + g.links.new(i.outputs['Ink Roughness'],ink.inputs['Roughness']);g.links.new(i.outputs['Sheen'],ink.inputs['Sheen Weight']) + ink.inputs['Specular IOR Level'].default_value=.16 + coat=node(g,'ShaderNodeBsdfPrincipled','Reflective coating') + coat.inputs['Metallic'].default_value=1 + g.links.new(i.outputs['Finish Roughness'],coat.inputs['Roughness']);g.links.new(i.outputs['Normal'],coat.inputs['Normal']) + g.links.new(i.outputs['Anisotropy'],coat.inputs['Anisotropic']) + # Tangent from UV gradients is provided by Blender; the mesh uses its active UV. + tangent=node(g,'ShaderNodeTangent','Surface direction');tangent.direction_type='UV_MAP' + g.links.new(tangent.outputs[0],coat.inputs['Tangent']) + geo=node(g,'ShaderNodeNewGeometry','Incoming view') + transform=node(g,'ShaderNodeVectorTransform','View in card space') + transform.vector_type='VECTOR';transform.convert_from='WORLD';transform.convert_to='OBJECT' + g.links.new(geo.outputs['Incoming'],transform.inputs[0]) + v=node(g,'ShaderNodeSeparateXYZ','Signed view');g.links.new(transform.outputs[0],v.inputs[0]) + uv=node(g,'ShaderNodeSeparateXYZ','Spatial phase');g.links.new(i.outputs['UV'],uv.inputs[0]) + phase=op(g,'ADD',op(g,'MULTIPLY',v.outputs[0],2.8),op(g,'MULTIPLY',v.outputs[1],1.6)) + phase=op(g,'ADD',phase,op(g,'MULTIPLY',uv.outputs[0],.65)) + phase=op(g,'ADD',phase,op(g,'MULTIPLY',uv.outputs[1],.25)) + phase=op(g,'FRACT',op(g,'ADD',phase,.3)) + spectrum=node(g,'ShaderNodeValToRGB','Broad reflection spectrum') + colors=[(0,(1,.32,.12,1)),(.2,(1,.84,.24,1)),(.4,(.12,1,.57,1)),(.6,(.12,.6,1,1)),(.8,(.68,.24,1,1)),(1,(1,.32,.12,1))] + spectrum.color_ramp.elements[0].color=colors[0][1];spectrum.color_ramp.elements[-1].color=colors[-1][1] + for p,c in colors[1:-1]:spectrum.color_ramp.elements.new(p).color=c + g.links.new(phase,spectrum.inputs[0]) + color=node(g,'ShaderNodeMixRGB','Foil or spectral reflection') + g.links.new(i.outputs['Holographic'],color.inputs[0]);g.links.new(i.outputs['Artwork'],color.inputs[1]);g.links.new(spectrum.outputs[0],color.inputs[2]) + g.links.new(color.outputs[0],coat.inputs['Base Color']) + facing=node(g,'ShaderNodeLayerWeight','Tilt activation') + angle=op(g,'ADD',.1,op(g,'MULTIPLY',smooth(g,facing.outputs['Facing'],.015,.38,'Holo tilt response'),.9)) + # Lerp between constant foil coverage and view-gated holo coverage. + gate=op(g,'ADD',1,op(g,'MULTIPLY',i.outputs['Holographic'],op(g,'SUBTRACT',angle,1))) + coverage=op(g,'MULTIPLY',i.outputs['Coverage'],i.outputs['Finish Strength']) + coverage=op(g,'MINIMUM',1,op(g,'MAXIMUM',0,op(g,'MULTIPLY',coverage,gate))) + mix=node(g,'ShaderNodeMixShader','Ink plus finish') + g.links.new(coverage,mix.inputs[0]);g.links.new(ink.outputs[0],mix.inputs[1]);g.links.new(coat.outputs[0],mix.inputs[2]);g.links.new(mix.outputs[0],o.inputs[0]) + for index,n in enumerate(g.nodes):n.location=((index%7)*220,-(index//7)*220) + return g + + +def create_card_finish_material(name, artwork, *, finish='foil', surface_texture='smooth', + finish_mask=None, mask_mode='automatic', strength=None, roughness=None, linen_strength=.38): + """Create/update a material; no scene, object, camera, or light mutations. + + artwork / finish_mask: bpy.types.Image. Optional masks must be Non-Color data. + finish: foil | holographic. surface_texture: smooth | paper | linen. + mask_mode: automatic | full. An explicit mask takes precedence. + Updates a named material in place: its existing users see the new settings. + """ + if finish not in {'foil','holographic'}:raise ValueError('Unknown finish') + if surface_texture not in {'smooth','paper','linen'}:raise ValueError('Unknown surface texture') + if mask_mode not in {'automatic','full'}:raise ValueError('Unknown mask mode') + if not isinstance(artwork,bpy.types.Image):raise TypeError('artwork must be a Blender image') + if finish_mask is not None and finish_mask.colorspace_settings.name!='Non-Color': + raise ValueError('Load finish_mask as Non-Color data') + if surface_texture!='smooth' and 'NG_CardMicroNormal' not in bpy.data.node_groups: + raise RuntimeError('Load/build NG_CardMicroNormal before using paper or linen') + strength=(.60 if finish=='holographic' else .56) if strength is None else strength + roughness=(.23 if finish=='holographic' else .19) if roughness is None else roughness + if not 0<=strength<=1 or not 0<=roughness<=1 or not 0<=linen_strength<=1: + raise ValueError('strength, roughness, linen_strength must be between zero and one') + mat=bpy.data.materials.get(name) or bpy.data.materials.new(name) + mat.use_nodes=True;t=mat.node_tree;t.nodes.clear() + uv=node(t,'ShaderNodeTexCoord','Artwork coordinates') + tex=node(t,'ShaderNodeTexImage','Front artwork');tex.image=artwork + t.links.new(uv.outputs['UV'],tex.inputs[0]) + shader=node(t,'ShaderNodeGroup','Finish parameters');shader.node_tree=finish_group() + t.links.new(tex.outputs['Color'],shader.inputs['Artwork']);t.links.new(uv.outputs['UV'],shader.inputs['UV']) + shader.inputs['Finish Strength'].default_value=strength;shader.inputs['Finish Roughness'].default_value=roughness + shader.inputs['Holographic'].default_value=float(finish=='holographic') + shader.inputs['Anisotropy'].default_value=.45 if finish=='holographic' else .32 + shader.inputs['Ink Roughness'].default_value=.77 if surface_texture=='linen' else (.61 if surface_texture=='paper' else .36) + shader.inputs['Sheen'].default_value=.16 if surface_texture=='linen' else 0 + if finish_mask: + mask=node(t,'ShaderNodeTexImage','Optional finish mask');mask.image=finish_mask + t.links.new(uv.outputs['UV'],mask.inputs[0]);t.links.new(mask.outputs['Color'],shader.inputs['Coverage']) + elif mask_mode=='automatic': + mask=node(t,'ShaderNodeGroup','Automatic color mask');mask.node_tree=auto_mask_group() + t.links.new(tex.outputs['Color'],mask.inputs[0]);t.links.new(mask.outputs[0],shader.inputs['Coverage']) + if surface_texture!='smooth': + micro=node(t,'ShaderNodeGroup','Surface texture');micro.node_tree=bpy.data.node_groups['NG_CardMicroNormal'] + micro.inputs['Linen Strength'].default_value=linen_strength if surface_texture=='linen' else 0 + micro.inputs['Micro Texture Strength'].default_value=.045 if surface_texture=='linen' else .08 + t.links.new(uv.outputs['Generated'],micro.inputs['Vector']);t.links.new(micro.outputs['Normal'],shader.inputs['Normal']) + else: + geo=node(t,'ShaderNodeNewGeometry','Smooth surface normal');t.links.new(geo.outputs['Normal'],shader.inputs['Normal']) + out=node(t,'ShaderNodeOutputMaterial','Card output');t.links.new(shader.outputs[0],out.inputs[0]) + for index,n in enumerate(t.nodes):n.location=((index%4)*260,-(index//4)*300) + for k,v in dict(frontTexture=artwork.filepath,finish=finish,surfaceTexture=surface_texture, + maskMode='explicit' if finish_mask else mask_mode,finishStrength=strength, + finishRoughness=roughness,linenStrength=linen_strength if surface_texture=='linen' else 0).items(): + mat['CardRenderConfig.'+k]=v + return mat diff --git a/sanctification-tcg/blender_prototype/premium_finishes.py b/sanctification-tcg/blender_prototype/premium_finishes.py new file mode 100644 index 0000000..df44e55 --- /dev/null +++ b/sanctification-tcg/blender_prototype/premium_finishes.py @@ -0,0 +1,145 @@ +"""Non-destructive material-only revision; call apply_premium_finishes() in Blender. + +UV protection regions are specific to legendary.png. Replace these regions when +using a different portrait; they are an editable prototype mask, not segmentation. +""" +import bpy + + +def node(tree, kind, name): + n = tree.nodes.new(kind) + n.name = n.label = name + return n + + +def math_node(t, op, a, b=0, name=None): + n = node(t, 'ShaderNodeMath', name or op) + n.operation = op + for i, value in enumerate((a, b)): + if isinstance(value, (float, int)): + n.inputs[i].default_value = value + else: + t.links.new(value, n.inputs[i]) + return n.outputs[0] + + +def ramp(t, value, lo, hi, name): + n = node(t, 'ShaderNodeMapRange', name) + n.clamp = True + n.interpolation_type = 'SMOOTHSTEP' + n.inputs['From Min'].default_value = lo + n.inputs['From Max'].default_value = hi + t.links.new(value, n.inputs['Value']) + return n.outputs[0] + + +def ellipse(t, uv, cx, cy, rx, ry, name): + x = math_node(t, 'DIVIDE', math_node(t, 'SUBTRACT', uv.outputs[0], cx), rx) + y = math_node(t, 'DIVIDE', math_node(t, 'SUBTRACT', uv.outputs[1], cy), ry) + d = math_node(t, 'ADD', math_node(t, 'MULTIPLY', x, x), math_node(t, 'MULTIPLY', y, y)) + return ramp(t, d, .65, 1.2, name) + + +def make_mask(): + name = 'NG_David_PrintProtection' + if name in bpy.data.node_groups: + return bpy.data.node_groups[name] + g = bpy.data.node_groups.new(name, 'ShaderNodeTree') + for nm, st in [('Artwork', 'NodeSocketColor'), ('UV', 'NodeSocketVector')]: + g.interface.new_socket(name=nm, in_out='INPUT', socket_type=st) + g.interface.new_socket(name='Finish Mask', in_out='OUTPUT', socket_type='NodeSocketFloat') + inp = node(g, 'NodeGroupInput', 'Source artwork and UV') + out = node(g, 'NodeGroupOutput', 'Protected decorative finish mask') + uv = node(g, 'ShaderNodeSeparateXYZ', 'Artwork coordinates') + g.links.new(inp.outputs['UV'], uv.inputs[0]) + hsv = node(g, 'ShaderNodeSeparateColor', 'Saturation selection') + hsv.mode = 'HSV' + g.links.new(inp.outputs['Artwork'], hsv.inputs[0]) + mask = ramp(g, hsv.outputs[1], .18, .62, 'Saturated glass and gold') + mask = math_node(g, 'MULTIPLY', mask, ramp(g, hsv.outputs[2], .015, .16, 'Protect dark ink')) + for cx, cy, rx, ry, label in [(.50,.72,.19,.23,'Face and neck'), + (.64,.44,.17,.13,'Hand'), + (.29,.44,.18,.18,'Lamb')]: + mask = math_node(g, 'MULTIPLY', mask, ellipse(g, uv,cx,cy,rx,ry,label)) + # Soft central rectangle covering cream title/rules panel; ornament remains. + x = math_node(g,'ABSOLUTE',math_node(g,'SUBTRACT',uv.outputs[0],.5)) + side = ramp(g,x,.34,.41,'Keep side ornament') + above = ramp(g,uv.outputs[1],.245,.29,'Protect title panel') + below = math_node(g,'SUBTRACT',1,ramp(g,uv.outputs[1],.07,.105,'Keep bottom ornament')) + panel = math_node(g,'MAXIMUM',side,math_node(g,'MAXIMUM',above,below)) + mask = math_node(g,'MULTIPLY',mask,panel) + g.links.new(mask,out.inputs[0]) + for i,n in enumerate(g.nodes): n.location=((i%8)*185,-(i//8)*170) + return g + + +def apply_premium_finishes(): + mask_group = make_mask() + variants = [('MAT_David_Foil',False,False),('MAT_David_Holo',True,False), + ('MAT_David_MetalHolo',True,True)] + for name,holo,metal in variants: + mat=bpy.data.materials[name] + t=mat.node_tree + image=next(n.image for n in t.nodes if n.type=='TEX_IMAGE') + t.nodes.clear() + uv=node(t,'ShaderNodeTexCoord','Card UV') + tex=node(t,'ShaderNodeTexImage','Printed David artwork');tex.image=image + t.links.new(uv.outputs['UV'],tex.inputs['Vector']) + mask=node(t,'ShaderNodeGroup','Protected finish regions');mask.node_tree=mask_group + t.links.new(tex.outputs['Color'],mask.inputs['Artwork']);t.links.new(uv.outputs['UV'],mask.inputs['UV']) + ink=node(t,'ShaderNodeBsdfPrincipled','Printed ink layer') + t.links.new(tex.outputs['Color'],ink.inputs['Base Color']) + ink.inputs['Roughness'].default_value=.36 + ink.inputs['Coat Weight'].default_value=0 + ink.inputs['Specular IOR Level'].default_value=.16 + ink.inputs['Coat Roughness'].default_value=.24 + finish=node(t,'ShaderNodeBsdfPrincipled','Diffractive reflection' if holo else 'Metal foil underprint') + finish.inputs['Metallic'].default_value=1 + finish.inputs['Roughness'].default_value=.23 if holo else .19 + finish.inputs['Anisotropic'].default_value=.45 if holo else .32 + tangent=node(t,'ShaderNodeTangent','UV groove direction');tangent.direction_type='UV_MAP';tangent.uv_map='UV_Card_Artwork' + t.links.new(tangent.outputs[0],finish.inputs['Tangent']) + strength=node(t,'ShaderNodeValue','Finish Strength');strength.outputs[0].default_value=.72 if metal else (.60 if holo else .56) + weight=math_node(t,'MULTIPLY',mask.outputs[0],strength.outputs[0],'Masked coating coverage') + if holo: + geo=node(t,'ShaderNodeNewGeometry','View direction') + transform=node(t,'ShaderNodeVectorTransform','View in card coordinates') + transform.vector_type='VECTOR';transform.convert_from='WORLD';transform.convert_to='OBJECT' + t.links.new(geo.outputs['Incoming'],transform.inputs['Vector']) + v=node(t,'ShaderNodeSeparateXYZ','Signed view components');t.links.new(transform.outputs[0],v.inputs[0]) + p=node(t,'ShaderNodeSeparateXYZ','Broad spatial phase');t.links.new(uv.outputs['UV'],p.inputs[0]) + phase=math_node(t,'ADD',math_node(t,'MULTIPLY',v.outputs[0],2.8),math_node(t,'MULTIPLY',v.outputs[1],1.6)) + phase=math_node(t,'ADD',phase,math_node(t,'MULTIPLY',p.outputs[0],.65)) + phase=math_node(t,'ADD',phase,math_node(t,'MULTIPLY',p.outputs[1],.25)) + phase=math_node(t,'FRACT',math_node(t,'ADD',phase,.3)) + spectrum=node(t,'ShaderNodeValToRGB','Broad diffraction spectrum') + colors=[(0,(1,.32,.12,1)),(.2,(1,.84,.24,1)),(.4,(.12,1,.57,1)),(.6,(.12,.6,1,1)),(.8,(.68,.24,1,1)),(1,(1,.32,.12,1))] + for e in list(spectrum.color_ramp.elements)[1:-1]:spectrum.color_ramp.elements.remove(e) + spectrum.color_ramp.elements[0].color=colors[0][1];spectrum.color_ramp.elements[-1].color=colors[-1][1] + for pos,c in colors[1:-1]:spectrum.color_ramp.elements.new(pos).color=c + t.links.new(phase,spectrum.inputs[0]);t.links.new(spectrum.outputs[0],finish.inputs['Base Color']) + facing=node(t,'ShaderNodeLayerWeight','Grazing angle activation') + activation=ramp(t,facing.outputs['Facing'],.015,.38,'Tilt response') + activation=math_node(t,'ADD',.10,math_node(t,'MULTIPLY',activation,.90)) + weight=math_node(t,'MULTIPLY',weight,activation,'Angle gated coating') + else: + t.links.new(tex.outputs['Color'],finish.inputs['Base Color']) + mix=node(t,'ShaderNodeMixShader','Printed ink plus reflective finish') + t.links.new(weight,mix.inputs[0]);t.links.new(ink.outputs[0],mix.inputs[1]);t.links.new(finish.outputs[0],mix.inputs[2]) + out=node(t,'ShaderNodeOutputMaterial','Card surface');t.links.new(mix.outputs[0],out.inputs[0]) + for i,n in enumerate(t.nodes):n.location=((i%7)*220,-(i//7)*240) + mat['Finish revision']='Reflective coating v3; protected print; no diffuse rainbow or thin-film outlines' + mat['CardRenderConfig.finishStrength']=strength.outputs[0].default_value + mat['CardRenderConfig.roughness']=finish.inputs['Roughness'].default_value + mat['CardRenderConfig.holo']=1.0 if holo else 0.0 + mat['CardRenderConfig.holo_groove']=0.0 + mat['CardRenderConfig.metallic']=1.0 + # Low-power vertical strip intersects the existing demo's +12 degree tilt. + from mathutils import Vector + strip=bpy.data.objects['LIGHT_Specular_Strip'] + strip.location=(.10,.012,.23) + strip.rotation_euler=(Vector((0,0,0))-strip.location).to_track_quat('-Z','Y').to_euler() + strip.data.energy=.65 + strip.data.size=.018 + strip.data.size_y=.14 + return {'materials':[v[0] for v in variants],'mask':mask_group.name} diff --git a/sanctification-tcg/blender_prototype/render_linen_foil_example.py b/sanctification-tcg/blender_prototype/render_linen_foil_example.py new file mode 100644 index 0000000..3c185ae --- /dev/null +++ b/sanctification-tcg/blender_prototype/render_linen_foil_example.py @@ -0,0 +1,45 @@ +"""Review at matched poses. Does not alter saved scene state.""" +import bpy +import math +from pathlib import Path + +s=bpy.context.scene +out=Path('/home/dkzver/dev/sanctification-tcg/blender_prototype/renders') +objs=[o for o in s.objects if o.name.startswith(('CARD_','LABEL_'))] +saved={o.name:(o.location.copy(),o.rotation_euler.copy(),o.hide_render) for o in objs} +cam=bpy.data.objects['CAM_Front_Inspection'] +cs=(cam.location.copy(),cam.rotation_euler.copy(),cam.data.ortho_scale) +settings=(s.camera,s.render.resolution_x,s.render.resolution_y,s.render.filepath) +def isolate(name,angle): + for o in objs:o.hide_render=o.name!=name + card=bpy.data.objects[name];card.location=(0,0,0);card.rotation_euler=(0,math.radians(angle),0) + s.camera=cam;s.render.resolution_x=750;s.render.resolution_y=1050 + return card +def shot(filename): + s.render.filepath=str(out/filename);bpy.ops.render.render(write_still=True) +try: + isolate('CARD_Timothy_LinenFoil',0);shot('timothy_linen_foil_head_on.png') + isolate('CARD_Timothy_LinenFoil',12);shot('timothy_linen_foil_reflection.png') + for name in ['Timothy_Linen','Timothy_LinenFoil']: + isolate('CARD_'+name,12);cam.data.ortho_scale=.036;cam.location.y=-.018 + shot(name.lower()+'_macro.png');cam.location=cs[0];cam.data.ortho_scale=cs[2] + for o in objs:o.hide_render=True + for name,x,label_text in [('Timothy_Linen',-.038,'Linen / Matte'),('Timothy_LinenFoil',.038,'Linen / Foil')]: + card=bpy.data.objects['CARD_'+name];card.hide_render=False;card.location=(x,0,0);card.rotation_euler=(0,math.radians(12),0) + label=bpy.data.objects['LABEL_'+name];label.hide_render=False;label.location=(x,-.050,.0015) + s.camera=cam;cam.data.ortho_scale=.16;s.render.resolution_x=1400;s.render.resolution_y=1050 + shot('timothy_linen_vs_linen_foil.png') + if globals().get('RENDER_MOTION',False): + card=isolate('CARD_Timothy_LinenFoil',0) + s.render.resolution_x=540;s.render.resolution_y=720;cam.data.ortho_scale=.108 + frames=out/'linen_foil_frames';frames.mkdir(exist_ok=True) + for f in range(72): + angle=-18+36*(.5-.5*math.cos(math.pi*f/71)) + card.rotation_euler=(0,math.radians(angle),0) + shot('linen_foil_frames/linen_foil_%04d.png'%(f+1)) +finally: + for o in objs: + loc,rot,h=saved[o.name];o.location=loc;o.rotation_euler=rot;o.hide_render=h + cam.location,cam.rotation_euler,cam.data.ortho_scale=cs + s.camera,s.render.resolution_x,s.render.resolution_y,s.render.filepath=settings +result={'directory':str(out),'restored_scene':True} diff --git a/sanctification-tcg/blender_prototype/render_premium_review.py b/sanctification-tcg/blender_prototype/render_premium_review.py new file mode 100644 index 0000000..4502d59 --- /dev/null +++ b/sanctification-tcg/blender_prototype/render_premium_review.py @@ -0,0 +1,57 @@ +"""Run with Blender MCP. REVIEW_ANIMATION=True also regenerates demo frames.""" +import bpy +import math +from pathlib import Path + +scene=bpy.context.scene +out=Path('/home/dkzver/dev/sanctification-tcg/blender_prototype/renders') +cards=[o for o in scene.objects if o.name.startswith('CARD_')] +labels=[o for o in scene.objects if o.name.startswith('LABEL_')] +saved={o.name:(o.location.copy(),o.rotation_euler.copy(),o.hide_render) for o in cards+labels} +camera=scene.camera +resolution=(scene.render.resolution_x,scene.render.resolution_y) +frame=scene.frame_current +path=scene.render.filepath +def render(name,target=None,tilt=0): + for o in cards:o.hide_render=(o.name!=target if target else o.name in {'CARD_Holo_Demo','CARD_Wear_Test'}) + for o in labels:o.hide_render=target is not None + scene.camera=bpy.data.objects['CAM_Front_Inspection' if target else 'CAM_Comparison'] + scene.render.resolution_x=750 if target else 1280 + scene.render.resolution_y=1050 if target else 900 + if target: + o=bpy.data.objects[target];o.location=(0,0,0);o.rotation_euler=(0,math.radians(tilt),0) + scene.render.image_settings.file_format='PNG' + scene.render.filepath=str(out/name) + bpy.ops.render.render(write_still=True) + if target: + o.location=saved[target][0];o.rotation_euler=saved[target][1] +try: + render('comparison_lineup.png') + render('timothy_matte_closeup.png','CARD_Timothy_Matte') + render('timothy_linen_closeup.png','CARD_Timothy_Linen',-14) + render('timothy_gloss_closeup.png','CARD_Timothy_Gloss',8) + render('david_paper_closeup.png','CARD_David_Paper') + render('wear_test_timothy.png','CARD_Wear_Test') + render('universal_card_back.png','CARD_Timothy_Matte',180) + render('david_foil_closeup.png','CARD_David_Foil',0) + render('david_foil_reflection.png','CARD_David_Foil',12) + render('david_holographic_head_on.png','CARD_David_Holo',0) + render('david_holographic_spectrum_angle.png','CARD_David_Holo',12) + render('david_holographic_opposite_angle.png','CARD_David_Holo',-18) + render('card_three_quarter_thickness.png','CARD_David_Foil',55) + if globals().get('REVIEW_ANIMATION',False): + for o in cards:o.hide_render=o.name!='CARD_Holo_Demo' + for o in labels:o.hide_render=True + scene.camera=bpy.data.objects['CAM_Front_Inspection'] + scene.render.resolution_x=540;scene.render.resolution_y=720 + (out/'holo_frames').mkdir(exist_ok=True) + scene.render.filepath=str(out/'holo_frames'/'holo_') + bpy.ops.render.render(animation=True) +finally: + scene.frame_set(frame) + for o in cards+labels: + loc,rot,hidden=saved[o.name];o.location=loc;o.rotation_euler=rot;o.hide_render=hidden + scene.camera=camera + scene.render.resolution_x,scene.render.resolution_y=resolution + scene.render.filepath=path +result={'render_directory':str(out),'animation':globals().get('REVIEW_ANIMATION',False)} diff --git a/sanctification-tcg/blender_prototype/renders/card_three_quarter_thickness.png b/sanctification-tcg/blender_prototype/renders/card_three_quarter_thickness.png new file mode 100644 index 0000000..dc683fe Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/card_three_quarter_thickness.png differ diff --git a/sanctification-tcg/blender_prototype/renders/comparison_lineup.png b/sanctification-tcg/blender_prototype/renders/comparison_lineup.png new file mode 100644 index 0000000..a65732a Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/comparison_lineup.png differ diff --git a/sanctification-tcg/blender_prototype/renders/comparison_material_revision.png b/sanctification-tcg/blender_prototype/renders/comparison_material_revision.png new file mode 100644 index 0000000..e8d8af5 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/comparison_material_revision.png differ diff --git a/sanctification-tcg/blender_prototype/renders/darker_lighting_preview.png b/sanctification-tcg/blender_prototype/renders/darker_lighting_preview.png new file mode 100644 index 0000000..31ba93b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/darker_lighting_preview.png differ diff --git a/sanctification-tcg/blender_prototype/renders/david_foil_closeup.png b/sanctification-tcg/blender_prototype/renders/david_foil_closeup.png new file mode 100644 index 0000000..d5791db Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/david_foil_closeup.png differ diff --git a/sanctification-tcg/blender_prototype/renders/david_foil_reflection.png b/sanctification-tcg/blender_prototype/renders/david_foil_reflection.png new file mode 100644 index 0000000..5fd5d9e Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/david_foil_reflection.png differ diff --git a/sanctification-tcg/blender_prototype/renders/david_holographic_head_on.png b/sanctification-tcg/blender_prototype/renders/david_holographic_head_on.png new file mode 100644 index 0000000..8b37d9b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/david_holographic_head_on.png differ diff --git a/sanctification-tcg/blender_prototype/renders/david_holographic_opposite_angle.png b/sanctification-tcg/blender_prototype/renders/david_holographic_opposite_angle.png new file mode 100644 index 0000000..fa8da74 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/david_holographic_opposite_angle.png differ diff --git a/sanctification-tcg/blender_prototype/renders/david_holographic_spectrum_angle.png b/sanctification-tcg/blender_prototype/renders/david_holographic_spectrum_angle.png new file mode 100644 index 0000000..2e12493 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/david_holographic_spectrum_angle.png differ diff --git a/sanctification-tcg/blender_prototype/renders/david_paper_closeup.png b/sanctification-tcg/blender_prototype/renders/david_paper_closeup.png new file mode 100644 index 0000000..d1ee10e Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/david_paper_closeup.png differ diff --git a/sanctification-tcg/blender_prototype/renders/foil_material_revision.png b/sanctification-tcg/blender_prototype/renders/foil_material_revision.png new file mode 100644 index 0000000..47e2c41 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/foil_material_revision.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0001.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0001.png new file mode 100644 index 0000000..256f9d2 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0001.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0002.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0002.png new file mode 100644 index 0000000..e591298 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0002.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0003.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0003.png new file mode 100644 index 0000000..09342c6 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0003.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0004.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0004.png new file mode 100644 index 0000000..461e73d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0004.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0005.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0005.png new file mode 100644 index 0000000..d4b88cd Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0005.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0006.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0006.png new file mode 100644 index 0000000..2df4e96 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0006.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0007.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0007.png new file mode 100644 index 0000000..9143996 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0007.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0008.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0008.png new file mode 100644 index 0000000..4f38582 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0008.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0009.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0009.png new file mode 100644 index 0000000..3e7f8e7 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0009.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0010.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0010.png new file mode 100644 index 0000000..4b097b7 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0010.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0011.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0011.png new file mode 100644 index 0000000..8517f57 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0011.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0012.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0012.png new file mode 100644 index 0000000..59cf109 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0012.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0013.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0013.png new file mode 100644 index 0000000..325801f Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0013.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0014.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0014.png new file mode 100644 index 0000000..e08297f Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0014.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0015.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0015.png new file mode 100644 index 0000000..e609d72 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0015.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0016.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0016.png new file mode 100644 index 0000000..24851a3 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0016.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0017.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0017.png new file mode 100644 index 0000000..39e7737 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0017.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0018.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0018.png new file mode 100644 index 0000000..b90e755 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0018.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0019.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0019.png new file mode 100644 index 0000000..7a03ca4 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0019.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0020.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0020.png new file mode 100644 index 0000000..4e6bdfc Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0020.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0021.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0021.png new file mode 100644 index 0000000..417a29a Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0021.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0022.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0022.png new file mode 100644 index 0000000..c28e37b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0022.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0023.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0023.png new file mode 100644 index 0000000..bcf9ed0 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0023.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0024.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0024.png new file mode 100644 index 0000000..70c0352 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0024.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0025.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0025.png new file mode 100644 index 0000000..53fcc9d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0025.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0026.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0026.png new file mode 100644 index 0000000..55eb622 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0026.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0027.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0027.png new file mode 100644 index 0000000..e5cfdb4 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0027.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0028.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0028.png new file mode 100644 index 0000000..a66a905 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0028.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0029.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0029.png new file mode 100644 index 0000000..b55b225 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0029.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0030.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0030.png new file mode 100644 index 0000000..ca6f8ee Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0030.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0031.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0031.png new file mode 100644 index 0000000..9066e95 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0031.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0032.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0032.png new file mode 100644 index 0000000..1d8167d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0032.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0033.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0033.png new file mode 100644 index 0000000..e89ef5f Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0033.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0034.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0034.png new file mode 100644 index 0000000..66a7768 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0034.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0035.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0035.png new file mode 100644 index 0000000..8b5a8e1 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0035.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0036.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0036.png new file mode 100644 index 0000000..088e325 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0036.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0037.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0037.png new file mode 100644 index 0000000..f26380b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0037.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0038.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0038.png new file mode 100644 index 0000000..8251404 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0038.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0039.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0039.png new file mode 100644 index 0000000..e51aeb2 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0039.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0040.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0040.png new file mode 100644 index 0000000..732dc79 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0040.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0041.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0041.png new file mode 100644 index 0000000..aa4a59c Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0041.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0042.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0042.png new file mode 100644 index 0000000..8cf0b85 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0042.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0043.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0043.png new file mode 100644 index 0000000..3af7402 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0043.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0044.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0044.png new file mode 100644 index 0000000..236323b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0044.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0045.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0045.png new file mode 100644 index 0000000..adeab51 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0045.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0046.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0046.png new file mode 100644 index 0000000..929fceb Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0046.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0047.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0047.png new file mode 100644 index 0000000..6ad4d24 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0047.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0048.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0048.png new file mode 100644 index 0000000..3113945 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0048.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0049.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0049.png new file mode 100644 index 0000000..f7a81e4 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0049.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0050.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0050.png new file mode 100644 index 0000000..180942a Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0050.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0051.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0051.png new file mode 100644 index 0000000..e0bcbd2 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0051.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0052.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0052.png new file mode 100644 index 0000000..bef9024 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0052.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0053.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0053.png new file mode 100644 index 0000000..29f5f70 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0053.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0054.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0054.png new file mode 100644 index 0000000..1baf4cb Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0054.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0055.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0055.png new file mode 100644 index 0000000..8f07782 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0055.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0056.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0056.png new file mode 100644 index 0000000..cebb843 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0056.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0057.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0057.png new file mode 100644 index 0000000..18c1039 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0057.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0058.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0058.png new file mode 100644 index 0000000..37f6c54 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0058.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0059.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0059.png new file mode 100644 index 0000000..beb0103 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0059.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0060.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0060.png new file mode 100644 index 0000000..c6a2cd7 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0060.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0061.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0061.png new file mode 100644 index 0000000..1079e1b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0061.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0062.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0062.png new file mode 100644 index 0000000..4031c88 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0062.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0063.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0063.png new file mode 100644 index 0000000..8154e35 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0063.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0064.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0064.png new file mode 100644 index 0000000..c8a8a47 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0064.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0065.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0065.png new file mode 100644 index 0000000..41b24b8 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0065.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0066.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0066.png new file mode 100644 index 0000000..e896213 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0066.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0067.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0067.png new file mode 100644 index 0000000..ab35362 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0067.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0068.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0068.png new file mode 100644 index 0000000..40733ab Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0068.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0069.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0069.png new file mode 100644 index 0000000..1ec19bc Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0069.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0070.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0070.png new file mode 100644 index 0000000..c1bc175 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0070.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0071.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0071.png new file mode 100644 index 0000000..f1d3095 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0071.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0072.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0072.png new file mode 100644 index 0000000..1817895 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0072.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0073.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0073.png new file mode 100644 index 0000000..d1eaaa7 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0073.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0074.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0074.png new file mode 100644 index 0000000..bd757e5 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0074.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0075.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0075.png new file mode 100644 index 0000000..f25da71 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0075.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0076.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0076.png new file mode 100644 index 0000000..d47e1bb Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0076.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0077.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0077.png new file mode 100644 index 0000000..969268b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0077.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0078.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0078.png new file mode 100644 index 0000000..3b13a84 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0078.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0079.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0079.png new file mode 100644 index 0000000..08c24b6 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0079.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0080.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0080.png new file mode 100644 index 0000000..f94602b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0080.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0081.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0081.png new file mode 100644 index 0000000..be9b914 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0081.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0082.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0082.png new file mode 100644 index 0000000..b237ce0 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0082.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0083.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0083.png new file mode 100644 index 0000000..9b1328d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0083.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0084.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0084.png new file mode 100644 index 0000000..3ae6260 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0084.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0085.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0085.png new file mode 100644 index 0000000..9b271db Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0085.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0086.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0086.png new file mode 100644 index 0000000..f61409e Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0086.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0087.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0087.png new file mode 100644 index 0000000..f0ae1be Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0087.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0088.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0088.png new file mode 100644 index 0000000..4dcb44d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0088.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0089.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0089.png new file mode 100644 index 0000000..f4c036c Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0089.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0090.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0090.png new file mode 100644 index 0000000..c05b788 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0090.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0091.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0091.png new file mode 100644 index 0000000..9a6403a Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0091.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0092.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0092.png new file mode 100644 index 0000000..ccd50c2 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0092.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0093.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0093.png new file mode 100644 index 0000000..c9405fd Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0093.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0094.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0094.png new file mode 100644 index 0000000..d909761 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0094.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0095.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0095.png new file mode 100644 index 0000000..acdae10 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0095.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0096.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0096.png new file mode 100644 index 0000000..74b8601 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0096.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0097.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0097.png new file mode 100644 index 0000000..9d93859 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0097.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0098.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0098.png new file mode 100644 index 0000000..1cdd17c Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0098.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0099.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0099.png new file mode 100644 index 0000000..476e954 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0099.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0100.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0100.png new file mode 100644 index 0000000..8101f22 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0100.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0101.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0101.png new file mode 100644 index 0000000..b1a8404 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0101.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0102.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0102.png new file mode 100644 index 0000000..feae283 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0102.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0103.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0103.png new file mode 100644 index 0000000..4b18ed5 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0103.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0104.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0104.png new file mode 100644 index 0000000..bb01788 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0104.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0105.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0105.png new file mode 100644 index 0000000..b4dbc12 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0105.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0106.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0106.png new file mode 100644 index 0000000..1ac0d3c Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0106.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0107.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0107.png new file mode 100644 index 0000000..a9c3b1a Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0107.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0108.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0108.png new file mode 100644 index 0000000..6e64f2d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0108.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0109.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0109.png new file mode 100644 index 0000000..452515e Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0109.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0110.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0110.png new file mode 100644 index 0000000..95df682 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0110.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0111.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0111.png new file mode 100644 index 0000000..52a5c03 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0111.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0112.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0112.png new file mode 100644 index 0000000..c695267 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0112.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0113.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0113.png new file mode 100644 index 0000000..765b4da Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0113.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0114.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0114.png new file mode 100644 index 0000000..5393213 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0114.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0115.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0115.png new file mode 100644 index 0000000..24d006d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0115.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0116.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0116.png new file mode 100644 index 0000000..f55f3de Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0116.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0117.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0117.png new file mode 100644 index 0000000..a15121b Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0117.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0118.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0118.png new file mode 100644 index 0000000..c05fe37 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0118.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0119.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0119.png new file mode 100644 index 0000000..2754e6d Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0119.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0120.png b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0120.png new file mode 100644 index 0000000..3c849d0 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_frames/holo_0120.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_material_revision_angle.png b/sanctification-tcg/blender_prototype/renders/holo_material_revision_angle.png new file mode 100644 index 0000000..8f541e8 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_material_revision_angle.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holo_material_revision_head_on.png b/sanctification-tcg/blender_prototype/renders/holo_material_revision_head_on.png new file mode 100644 index 0000000..04b0091 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holo_material_revision_head_on.png differ diff --git a/sanctification-tcg/blender_prototype/renders/holographic_rotation_demo.mp4 b/sanctification-tcg/blender_prototype/renders/holographic_rotation_demo.mp4 new file mode 100644 index 0000000..220dfbf Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/holographic_rotation_demo.mp4 differ diff --git a/sanctification-tcg/blender_prototype/renders/lighting_revision_preview.png b/sanctification-tcg/blender_prototype/renders/lighting_revision_preview.png new file mode 100644 index 0000000..b5d21f3 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/lighting_revision_preview.png differ diff --git a/sanctification-tcg/blender_prototype/renders/linen_material_revision.png b/sanctification-tcg/blender_prototype/renders/linen_material_revision.png new file mode 100644 index 0000000..2338357 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/linen_material_revision.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_gloss_closeup.png b/sanctification-tcg/blender_prototype/renders/timothy_gloss_closeup.png new file mode 100644 index 0000000..e3b58a5 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_gloss_closeup.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_linen_closeup.png b/sanctification-tcg/blender_prototype/renders/timothy_linen_closeup.png new file mode 100644 index 0000000..ac19358 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_linen_closeup.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_linen_foil_head_on.png b/sanctification-tcg/blender_prototype/renders/timothy_linen_foil_head_on.png new file mode 100644 index 0000000..dd16a54 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_linen_foil_head_on.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_linen_foil_reflection.png b/sanctification-tcg/blender_prototype/renders/timothy_linen_foil_reflection.png new file mode 100644 index 0000000..1b2d925 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_linen_foil_reflection.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_linen_macro.png b/sanctification-tcg/blender_prototype/renders/timothy_linen_macro.png new file mode 100644 index 0000000..d6eef58 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_linen_macro.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_linen_vs_linen_foil.png b/sanctification-tcg/blender_prototype/renders/timothy_linen_vs_linen_foil.png new file mode 100644 index 0000000..03b9ddd Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_linen_vs_linen_foil.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_linenfoil_macro.png b/sanctification-tcg/blender_prototype/renders/timothy_linenfoil_macro.png new file mode 100644 index 0000000..3bd5548 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_linenfoil_macro.png differ diff --git a/sanctification-tcg/blender_prototype/renders/timothy_matte_closeup.png b/sanctification-tcg/blender_prototype/renders/timothy_matte_closeup.png new file mode 100644 index 0000000..2fa96a2 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/timothy_matte_closeup.png differ diff --git a/sanctification-tcg/blender_prototype/renders/universal_card_back.png b/sanctification-tcg/blender_prototype/renders/universal_card_back.png new file mode 100644 index 0000000..df60737 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/universal_card_back.png differ diff --git a/sanctification-tcg/blender_prototype/renders/wear_test_timothy.png b/sanctification-tcg/blender_prototype/renders/wear_test_timothy.png new file mode 100644 index 0000000..f482c43 Binary files /dev/null and b/sanctification-tcg/blender_prototype/renders/wear_test_timothy.png differ diff --git a/sanctification-tcg/blender_prototype/sanctification_card_material_prototype.blend b/sanctification-tcg/blender_prototype/sanctification_card_material_prototype.blend new file mode 100644 index 0000000..c88cc5a Binary files /dev/null and b/sanctification-tcg/blender_prototype/sanctification_card_material_prototype.blend differ diff --git a/sanctification-tcg/blender_prototype/sanctification_card_material_prototype.blend1 b/sanctification-tcg/blender_prototype/sanctification_card_material_prototype.blend1 new file mode 100644 index 0000000..7f542cf Binary files /dev/null and b/sanctification-tcg/blender_prototype/sanctification_card_material_prototype.blend1 differ diff --git a/sanctification-tcg/blender_prototype/timothy_linen_foil.py b/sanctification-tcg/blender_prototype/timothy_linen_foil.py new file mode 100644 index 0000000..079088b --- /dev/null +++ b/sanctification-tcg/blender_prototype/timothy_linen_foil.py @@ -0,0 +1,37 @@ +"""Build the linen+foil example using the generic material API.""" +import bpy +import runpy +from pathlib import Path + + +def add_timothy_linen_foil(): + create=runpy.run_path(str(Path(__file__).with_name('card_finish_material.py')))['create_card_finish_material'] + mat=create('MAT_Timothy_LinenFoil',bpy.data.images['TEX_Timothy_Front'], + finish='foil',surface_texture='linen',mask_mode='automatic', + strength=.56,roughness=.23,linen_strength=.38) + root=bpy.data.collections['Cards'] + col=bpy.data.collections.get('Timothy_LinenFoil') + if col is None: + col=bpy.data.collections.new('Timothy_LinenFoil');root.children.link(col) + card=bpy.data.objects.get('CARD_Timothy_LinenFoil') + if card is None: + card=bpy.data.objects['CARD_Timothy_Linen'].copy();card.name='CARD_Timothy_LinenFoil' + card.animation_data_clear();col.objects.link(card) + card.material_slots[0].link='OBJECT';card.material_slots[0].material=mat + card['CardRenderConfig.frontMaterial']=mat.name + card['CardRenderConfig.finish']='foil';card['CardRenderConfig.surfaceTexture']='linen' + card.hide_render=False + for name,x in [('Timothy_Matte',-.123),('Timothy_Linen',-.041),('Timothy_Gloss',.041),('Timothy_LinenFoil',.123)]: + obj=bpy.data.objects['CARD_'+name];obj.location=(x,.061,0) + label=bpy.data.objects.get('LABEL_'+name) + if label is None: + source=bpy.data.objects['LABEL_Timothy_Linen'] + label=source.copy();label.data=source.data.copy();label.name='LABEL_'+name + label.data.name='TXT_'+name;label.data.body='Timothy Linen Foil';label.data.size=.0047 + bpy.data.collections['Labels'].objects.link(label) + label.location.x=x;label.location.y=.010 + return {'card':card.name,'material':mat.name,'mesh':card.data.name,'mask':'automatic color mask; no artwork regions'} + + +if __name__=='__main__': + result=add_timothy_linen_foil() diff --git a/sanctification-tcg/card-back.png b/sanctification-tcg/card-back.png new file mode 100644 index 0000000..caa4df8 Binary files /dev/null and b/sanctification-tcg/card-back.png differ diff --git a/sanctification-tcg/card-harness/.gitignore b/sanctification-tcg/card-harness/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/sanctification-tcg/card-harness/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/sanctification-tcg/card-harness/CURRENT-DEFAULTS.md b/sanctification-tcg/card-harness/CURRENT-DEFAULTS.md new file mode 100644 index 0000000..0fe5354 --- /dev/null +++ b/sanctification-tcg/card-harness/CURRENT-DEFAULTS.md @@ -0,0 +1,283 @@ +# Sanctification Card Lab - Current Defaults + +## Approved Runtime Look + +- Revision: `runtime-look-v4-2026-09-07` +- Approved: September 7, 2026 +- Scope: card geometry, front-face materials, substrate distinction, foil, holographic response, and current finish-mask policy +- Follow-up approval: rigid pack prototype and artifact-free individual card inspection; retained as the foundation for the foil iteration below +- Not yet approved: the new foil treatment/choreography, lighting presets, mobile performance, or wear + +These values represent the current visual baseline. Changes should be deliberate and compared against this revision rather than treated as incidental shader cleanup. + +## Geometry + +The card is generated procedurally at runtime. Blender and GLB files are not required. + +| Property | Physical value | Runtime value | +|---|---:|---:| +| Width | 63 mm | 2.8571 scene units | +| Height | 88.2 mm | 4 scene units | +| Thickness | 0.4 mm | 0.01814 scene units | +| Corner radius | 2.8 mm | 0.12698 scene units | +| Corner segments | - | 16 | + +Front and back use normalized UV coordinates with no runtime mirroring. The front, back, and edge are separate material surfaces. +The edge extrusion renders only side-wall triangles; its generated end caps are +excluded from the draw range. Front and back sit exactly at half the physical +thickness, with no almost-coplanar cap underlay or artificial depth offset. + +### Reveal-Safe Card Back + +The universal card back is intentionally independent from the selected front finish, substrate, finish strength, surface detail, and condition. This prevents the hidden card identity or variant from being inferred during a pack reveal. + +| Property | Fixed value | +|---|---:| +| Roughness | 0.42 | +| Metalness | 0 | +| Clear coat | 0.20 | +| Clear-coat roughness | 0.24 | +| Normal map | None | + +The physical edge remains substrate-aware for close inspection. Pack-opening choreography should keep the thin edge from becoming a deliberate pre-reveal inspection surface. + +## Renderer + +| Setting | Default | +|---|---| +| Output color space | sRGB | +| Tone mapping | ACES Filmic | +| Exposure | 1.0 | +| Pixel ratio | `min(devicePixelRatio, 2)` | +| Scene background | `#0b0e14` | +| Environment | Generated 128 px studio cube texture | + +## Camera and Reset Pose + +| Setting | Default | +|---|---:| +| Perspective field of view | 34 degrees | +| Near / far planes | 0.1 / 100 | +| Camera position | `(0, 0, 8.2)` | +| Orbit target | `(0, 0, 0)` | +| Orbit distance | 5-12 | +| Card rotation | `(-0.06, 0.12, 0)` radians | + +Available inspection poses: + +| Pose | Card rotation | +|---|---| +| Front | `(-0.06, 0.12, 0)` | +| Grazing | `(-0.14, 52 degrees, 0)` | +| Edge | `(-0.05, 90 degrees, 0)` | +| Back | `(-0.06, 180 degrees + 0.12 radians, 0)` | +| Free | Current manually controlled rotation | + +Reset restores the Front pose, David, Holographic finish, Paper substrate, default surface values, default camera, and default light. + +## Light + +| Setting | Default | +|---|---| +| Type | Point light | +| Color | `#fff0d0` | +| Position | `(2.2, 2.5, 4.4)` | +| Shader intensity | 3.0 | +| Three.js light intensity | 34 | +| Distance / decay | 20 / 1.7 | +| Environment intensity | 0.7 | + +Lighting presets are available for repeatable evaluation but are not yet visually approved: + +| Preset | Type | Color | Position | Intensity | Environment | Exposure | +|---|---|---|---|---:|---:|---:| +| Studio | Point | `#fff0d0` | `(2.2, 2.5, 4.4)` | 3.0 | 0.70 | 1.00 | +| Warm gallery | Spot | `#ffd29a` | `(-2.8, 3.1, 4.8)` | 4.0 | 0.52 | 1.04 | +| Cool window | Directional | `#bddcff` | `(-3.4, 4.2, 5.2)` | 2.7 | 0.78 | 1.00 | +| Dramatic spot | Spot | `#ffe1ad` | `(3.6, 1.9, 3.6)` | 4.8 | 0.28 | 0.92 | +| Flat review | Directional | `#ffffff` | `(0.6, 0.8, 5.5)` | 2.25 | 1.00 | 1.00 | + +Changing a light type, color, position, intensity, environment contribution, or exposure marks the lighting configuration as Custom. + +## User-Facing Surface Controls + +| Control | Default | Available range | +|---|---:|---:| +| Finish | Holographic | Printed ink / Foil / Holographic | +| Material | Paper | Paper / Linen / Plastic / Metal / Wood | +| Finish strength | 0.60 | 0-1 | +| Roughness | 0.23 | 0.05-0.8 | +| Surface detail | 0.14 | 0-0.45 | +| Environment intensity | 0.70 | 0-1.5 | +| Condition | 1.0 (mint) | 0-1 | +| Imperfection seed | 81251 | 1-99999 | + +Finish and material are independent concepts, but combinations should exist only when they have an intentional visual interpretation. + +## Substrate Recipes + +### Paper + +- Roughness floor: `0.56` +- Fiber frequency: `230 x 322` +- Relief amplitude: `0.0014 * detail` +- Broad reflection strength: `0.045` +- Edge: `#9c8c69`, roughness `0.62`, metalness `0.02`, clear coat `0.02` + +### Linen + +- Roughness floor: `0.70` +- Interlaced thread frequency: `52 x 73` +- Relief amplitude: `0.005 * detail` +- Reflection strength: `0.025` +- Edge: `#a89772`, roughness `0.78`, metalness `0`, clear coat `0` + +### Plastic + +- Roughness ceiling: `0.25` +- Dielectric Fresnel base: `0.04` +- Reflected fraction cap: `0.32` +- Direct highlight cap: `0.50` +- Edge: `#d4cab0`, roughness `0.24`, metalness `0`, clear coat `0.72` + +### Metal + +- Roughness ceiling: `0.20` +- Artwork-driven etch: darker values are recessed more deeply +- Etch relief amplitude: `-0.0065 * depth * min(detail, 1.5)` +- Cavity shading: `0.12` on the metal underprint and `0.06` on the combined printed result +- Metal underprint blend: `0.42` +- Edge: `#a8adb7`, roughness `0.20`, metalness `0.86`, clear coat `0.18` + +Metal + Foil is a special intentional combination: + +- Preserves printed artwork colors +- Reduces etch depth beneath the foil mask by at most 25% +- Adds only `0.0005 * mask * detail` of raised relief +- Uses restrained champagne tint `#ffd694` +- Plating weight is capped at `0.34` +- Must not turn the illustration into opaque gold + +### Wood + +- Roughness floor: `0.48` +- Warped longitudinal grain frequency: `170` +- Relief amplitude: `0.007 * detail` +- Warm tint blend: `0.25` +- Reflection strength: `0.09` +- Edge: `#6b3f21`, roughness `0.56`, metalness `0`, clear coat `0.08` + +## Finish Recipes + +### Printed Ink + +- Returns the substrate response without an additional coating. +- Substrate relief and reflection must remain visible. + +### Foil + +- Uses the finish mask as selective coverage. +- Effective finish strength: `finishStrength * 0.8` +- Maximum coating weight: `0.48` +- Coating response cap: `0.78` +- Reflection is warm and screened with the original artwork rather than replacing it. +- Reduced sheen applies only to Foil, not Holographic. + +### Holographic + +- Effective finish strength: `finishStrength` +- Maximum coating weight: `0.92` +- Coating response cap: `1.8` +- Spectral reflection blend: `0.72` +- Artwork-to-spectral coating blend: `0.48` +- The approved spectrum uses UV, view direction, and light direction so color shifts while the card moves. +- Do not weaken the holographic color response to solve mask problems; correct the mask instead. + +## Finish Masks + +### David + +- Saturation threshold: `smoothstep(0.18, 0.62)` +- Value threshold: `smoothstep(0.015, 0.16)` +- Retains soft title-panel protection. +- Does not use circular or elliptical exclusions over the face, hand, or lamb. + +### Timothy + +- Saturation threshold: `smoothstep(0.08, 0.58)` +- Value threshold: `smoothstep(0.015, 0.16)` + +All finish masks should: + +- Protect critical text unless a treatment is intentionally designed for it. +- Follow compositional regions such as stained-glass panes, ornament, borders, garments, and environmental color. +- Avoid artificial holes, isolated skin spots, and recognizable geometric exclusions. +- Be reviewed while the card rotates under multiple lighting angles. + +## Wear and Imperfections + +Condition uses `1.0` for mint and `0.0` for the most worn supported appearance. The approved v4 material baseline remains unchanged at the mint default. + +- Edge wear uses seeded irregular width and substrate-appropriate exposed colors. +- Three deterministic fine scratches and two deterministic scuff regions are generated from the imperfection seed. +- Scratches and scuffs perturb the surface normal slightly and reduce finish coverage where the coating is damaged. +- Paper and linen expose pale fibers, plastic becomes cloudy, metal exposes brighter raw metal, and wood exposes a lighter brown layer. +- Wear remains bounded so the card artwork stays readable even at condition `0.0`. + +## Local Asset Evaluation + +Lab mode can replace the active artwork or finish mask with a local image through the browser file picker. Local files remain in browser memory, are not copied into the project, and are discarded when the page reloads or the selected fixture is restored. Reset restores the David reference artwork and mask. + +## Experiment Snapshots + +Lab mode can save named experiments in browser storage, export or import a versioned JSON snapshot, and capture the current canvas as PNG. Snapshot schema version 2 records: + +- Runtime-look revision and save time +- Reference fixture and custom asset filenames +- Finish, substrate, and surface controls +- Condition and imperfection seed +- Lighting preset, environment, type, color, position, intensity, and exposure +- Card rotation, camera position, and orbit target + +Local image contents are intentionally not embedded in snapshots. When a snapshot records custom artwork or a custom mask, those files must be loaded again after restoring the configuration. + +Schema version 1 snapshots remain loadable and restore as mint condition with seed `81251`. + +## Source of Truth + +- Geometry: `src/cardGeometry.ts` +- Runtime shader: `src/cardMaterial.ts` +- Scene and control defaults: `src/main.ts` +- Generated asset contract: `public/reference/manifest.json` +- Art policy: `../art-direction.md` + +## Tear-Open Foil Pack (Next Review Iteration) + +Pack is a third mode beside Inspect and Lab. It does not revise +`runtime-look-v4-2026-09-07`, saved experiments, or the reference asset contract. + +- Controller: `src/packOpening.ts`; foil meshes: `src/foilWrapper.ts`; integration and lazy local-asset loading: `src/main.ts`. +- States: `sealed → opening → stackReady → lifting → lifted → revealing → inspecting → advancing → stackReady / complete`. +- Order: David / Printed ink / Linen; Timothy / Foil / Paper; David / Holographic / Metal. Surface defaults remain strength `0.60`, roughness `0.23`, detail `0.14`, mint condition and seed `81251`. Pack inherits the current lighting. +- Wrapper: two `64 × 80` subdivided pouch surfaces joined at their side/bottom boundaries and one `64 × 10` top ribbon, sharing horizontal samples along the attached seam. Total 21,760 triangles. Dimensions: width `3.36`, bottom `-2.24`, tear seam `1.97`, top `2.28`; center depth `-0.09`, inflated half-depth `0.37`. Surface folds are shallow deterministic UV-based displacements; contact dent is bounded to `0.045` units. Side and bottom crimps remain separated surfaces, with no overlapping cap/label meshes or transparent fades. +- Foil material: deep teal printed canvas, warm gold seam and restrained typography; standard material roughness `0.43`, metalness `0.68`, double-sided. The existing scene lighting/environment lights the foil. This is a wrapper-specific recipe, not a card-material revision. +- Opening lasts `3200 ms` from sealed (remaining duration scales with retained progress); lift `620 ms`, reveal `820 ms`, advance `650 ms`. Elapsed-time smoothstep interpolation and quaternion rotation remain unchanged for card transitions. +- Opening's eased progress intervals: tear `0–0.38`; strip detachment/curl-away `0.38–0.52`; mouth spread `0.50–0.62`; card extraction `0.62–0.826`; empty pouch exit `0.83–0.87`; stack settling `0.87–1`. Each successive card starts/ends extraction `0.018` progress later, staying at its established depth and scale. Extraction rises `4.4` units, clearing the entire pouch before its lateral exit. The wrapper never drops through the floor or crosses the inspecting card. +- Mouth lips spread in depth to a `0.64` half-opening envelope; the front lip bows down `0.28` units and the rear up `0.08`, exposing the opening without added rim layers. The released ribbon curls up/back around the moving tear front, then moves right and away before extraction begins. +- Seam input: a projected mouse/touch hit area at least `44 px` high; a rightward drag across `75%` of projected pack width (at least `80 px`) traverses a full tear. Progress is monotonic and survives partial release, pointer cancellation/lost capture and mode switches. A completed pull releases into the remaining choreography; otherwise **Finish opening** or another seam drag resumes. **Tear open** is the button/keyboard equivalent. The progress indicator exposes the tear percentage. +- Reduced motion: `70 ms` per transition, with identical resting states and event order. +- Input ownership: the Pack controller alone writes Pack transforms. Manual input pauses scripted motion without snapping; Continue resumes the elapsed transition. The seam gesture can take over a still-tearing animation at its current progress. A second touch cancels seam ownership in favor of pinch; wheel zoom releases captured gestures. Rotation is allowed only after revealing; zoom is available throughout. Press/release contact deformation is damped, except under reduced motion where it is immediate. +- Skip finishes one transition or one next resting-state step. Restart clears progress and event guards. Mode changes pause Pack while retaining the separate Inspect/Lab scene and assets. +- Hidden fronts are disabled, all backs share the universal fixed material, and hidden edges use the same mint paper recipe. Real substrate edges become visible only after the reveal finishes. +- Lift ownership: the existing stack card is the animated object, with the same root and mesh UUIDs throughout. There is no separate active-card renderer or stack placeholder. All unrevealed fronts remain disabled throughout the lift. +- Stack depth spacing: `0.055` scene units between roots at `0.88` scale, leaving approximately `0.039036` between neighboring surfaces. The current card lifts away while reserve cards ease to a visible lower-right position, preserving their depth spacing and face-down orientation. +- Inspection clearance: the full card corner-sweep radius is derived from the shared scene dimensions (approximately `2.458`). Lift finishes at local `z = max(3, radius + 0.5)` before reveal or manual rotation. With current dimensions, even the conservative full-rotation envelope stays over `0.61` units ahead of the reserve stack and over `0.60` above the floor. The opened wrapper is hidden and sits outside this envelope. +- Reserve cards remain visible through reveal, inspection, and exit. They are offset by `(1.05, -0.4, 0)` from their normal stack poses while inspecting, then recenter during advancing. The active card exits laterally on the same safe inspection plane, never diving back through the reserve stack. +- Pack uses a separate responsive 34-degree camera, fitted to the wrapper and compact bottom controls; pinch/wheel zoom spans `0.62–1.45` of its fitted distance. Opening-only framing retreats up to `8` units and raises its center `1.6` units during extraction, returning fully before stackReady. A `2.15`-unit camera retreat eases with the lift and reverses on exit to preserve the established card framing at the deeper inspection plane. One camera-update helper combines retreat, responsive fit, vertical framing, and user zoom; paused motion preserves them all. Inspect/Lab camera defaults are unchanged. Aggressive user zoom may crop the scene, as in the existing inspection flow. + +No pack progress persistence, random rewards, backend, sound, haptics, particles, +cloth, or general timeline/editor are included. Deformation is an authored, reversible +evaluation of progress, not physical tearing or arbitrary cloth manipulation. The +tear itself cannot be reversed by dragging left; only Restart reseals the pack. +Wrapper art and the new foil choreography remain review-only. Desktop browser and +emulated mobile checks are not physical-device performance or touch-feel sign-off. diff --git a/sanctification-tcg/card-harness/README.md b/sanctification-tcg/card-harness/README.md new file mode 100644 index 0000000..56d8121 --- /dev/null +++ b/sanctification-tcg/card-harness/README.md @@ -0,0 +1,130 @@ +# Sanctification Card Lab + +Bounded Three.js proof for the card-rendering harness plan. + +The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md). + +## Included proof scope + +- David printed-ink, foil, and holographic presets +- Independently selectable paper, linen, plastic, metal, and wood substrates +- Timothy as the second-artwork fixture +- David color-based coverage and title-panel protection without face/hand/lamb cutouts; automatic saturation/value coverage for Timothy +- Procedural rounded-card geometry generated from shared physical dimensions +- Deterministic linen normal map +- Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting +- Repeatable front, grazing-angle, edge, and back inspection poses +- Point, directional, and spot lighting with repeatable studio, gallery, window, dramatic, and flat-review presets +- Live light color, position, intensity, environment contribution, and exposure controls +- Lab-mode loading of local artwork and finish-mask images without rebuilding +- Named browser experiment saves, versioned JSON import/export, and PNG capture +- Deterministic condition wear with substrate-aware edges, scratches, scuffs, and imperfection seeds +- Bare Inspect mode and small Lab-mode parameter panel +- Pack mode: a touch-responsive foil pouch with a draggable tear strip, reveal-safe ordered stack, and one-at-a-time inspection +- Drawing-buffer resolution plus median, p95, and worst frame-time display + +Transform gizmos and a general timeline are intentionally deferred. The separate Flutter Scene comparison project is unchanged; Three.js remains the authoritative runtime. + +## Tear-open foil pack prototype + +Choose **Pack** in the Mode selector (expand **Controls** first on mobile). +Drag the gold top seam **to the right**, or use **Tear open** for the same authored opening without dragging: + +**Sealed → progressive tear → strip curls away → mouth spreads → staggered card extraction → face-down stack → lifting → lifted → revealing → inspecting → advancing → next stack / complete.** + +Lifting and flipping are separate actions so the universal back can be reviewed before revealing. +The authored order is always David / Printed ink / Linen, Timothy / Foil / Paper, +then David / Holographic / Metal. This is not a randomized reward system. + +- The seam has a mouse/touch hit area at least 44 px tall. Drag distance drives tear progress monotonically: pulling back does not reseal it. A full pull completes the opening on release; a short pull holds its exact tear and **Finish opening** continues from there. The progress bar and percentage also expose the state without relying on the animation. +- Cancellation or lost pointer capture holds a partial tear; another seam pull, **Finish opening**, or **Skip** can continue it. A second finger relinquishes the tear gesture to pinch zoom. Restart and mode changes release all captured pointers. +- Pressing the pouch makes a small local dent that settles on release. Creases and seam crimps are deterministic, not continuously moving noise. +- Drag to rotate and pinch or wheel to zoom during inspection. Pinch/wheel zoom also works before revealing; hidden cards cannot be manually tilted. User zoom may intentionally crop the composition. +- Touching or zooming during a scripted transition pauses at the current pose. **Continue** resumes from that elapsed point, without snapping. A tap that interrupts motion does not also advance it. +- **Skip** finishes only the current transition, or performs one step immediately from a resting state. It never skips an unseen card's reveal. **Restart** restores the sealed wrapper and first card. +- Reduced-motion preference shortens each transition to 70 ms with the same state and reveal/completion ordering. +- Switching away pauses Pack and preserves the Inspect/Lab card, camera, materials, local images and experiment settings. Returning to Pack resumes its retained state via Continue. + +The foil wrapper is two subdivided, joined pouch surfaces and a separate subdivided +top ribbon. Controlled vertex deformation creates shallow creases, a travelling tear +front, a curled detached strip, and spreading lips. Typography is printed directly +on the sheet materials, not on an almost-coplanar label layer. There is no rigid-panel +split, transparent cross-fade, cloth engine, or extra asset dependency. +The pouch stays above the floor while the three cards slide upward with a small +stagger. Only after all cards clear the mouth does the empty pouch move aside; the +cards then settle into the existing face-down stack. Opening-only camera framing +widens and recenters during extraction, then returns to the established stack framing. + +Cards use the existing procedural geometry and approved runtime shader. +Before revealing, fronts are hidden, backs share the fixed universal material, and +edges use a neutral paper recipe. Authored substrate edges are enabled only after +the flip finishes. The active card lifts to a dedicated inspection plane before +flipping or free rotation. The reserve stack stays visible, offset to the lower +right and safely behind the active card's full corner-sweep volume. The exit +animation also stays on that safe plane while the reserve stack recenters. +Camera framing eases with the lift/exit so the deeper separation does not +unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, not end caps beneath the +dedicated artwork faces, preventing depth-fighting bands at farther zoom distances. +Pack assets are loaded separately on first entry, with retry on failure. + +There is no sound, haptics, particles, cloth simulation, backend, rewards persistence, +pack progress persistence, or timeline/editor. This is a choreography proof, not an +final pack-design or mobile performance sign-off. The approved rigid prototype and +artifact-free card inspection are the preserved foundation; this foil treatment is +the next review iteration. The canvas emits `packreveal` +(one-based index and variant) and `packcomplete` (count), once per card/pack per restart. + +Finish and substrate are separate: + +- Finish controls printed ink, foil, or holographic coating. +- Substrate controls surface roughness/microtexture, underprint response, and edge/core appearance. + +Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised +interlaced threads with small shaded recesses. Plastic has a smooth clear-coat +highlight even in Printed ink mode. Wood uses warped longitudinal grain with +surface relief and a restrained warm tint. Metal retains its reflective +underprint and adds a shallow luminance-driven etch, with darker artwork +recessed slightly more than lighter artwork. Procedural detail fades below +pixel resolution to limit shimmer. +The universal card back uses fixed neutral material properties so front finish +and substrate choices do not spoil a pack reveal. +The holo spectrum and coverage rules are independent of these substrate changes. +Metal combined with Foil preserves the printed colors while adding restrained, +slightly raised champagne-metal polishing over the etched steel base. + +## Reference + +`npm run assets` creates `public/reference/manifest.json`, copies the source artwork and approved reference renders, and generates the two masks and normal map. Card geometry is generated directly in Three.js from the dimensions recorded in the manifest; Blender is not required to build or run the harness. The current runtime reference revision is `runtime-look-v4-2026-09-07`, visually informed by: + +- `blender_prototype/premium_finishes.py` +- `blender_prototype/card_finish_material.py` +- `blender_prototype/render_premium_review.py` + +The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output. +Live review removed only David's face, hand, and lamb ellipse cutouts. Color-based coverage and title-panel protection remain. Holographic color and angle response are preserved; reduced sheen applies only to foil. + +## Run + +```sh +npm install +npm run dev +``` + +Production build: + +```sh +npm run build +``` + +The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline. + +## Baseline + +Initial development machine: + +- Windows +- Intel Arc Graphics, driver `32.0.101.6737` +- Node `24.14.1` +- Three.js version pinned by `package-lock.json` + +The browser/version, viewport, drawing-buffer resolution, texture settings, and observation duration must be recorded during material approval. The on-screen frame-time display is diagnostic rather than a complete benchmark. diff --git a/sanctification-tcg/card-harness/index.html b/sanctification-tcg/card-harness/index.html new file mode 100644 index 0000000..fc89fe0 --- /dev/null +++ b/sanctification-tcg/card-harness/index.html @@ -0,0 +1,13 @@ + + + + + + + Sanctification Card Lab + + +
+ + + diff --git a/sanctification-tcg/card-harness/pack-foil-review/01-sealed-desktop.png b/sanctification-tcg/card-harness/pack-foil-review/01-sealed-desktop.png new file mode 100644 index 0000000..c00213e Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/01-sealed-desktop.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/02-partial-tear-desktop.png b/sanctification-tcg/card-harness/pack-foil-review/02-partial-tear-desktop.png new file mode 100644 index 0000000..5b9739b Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/02-partial-tear-desktop.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/03-nearly-torn.png b/sanctification-tcg/card-harness/pack-foil-review/03-nearly-torn.png new file mode 100644 index 0000000..bac6816 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/03-nearly-torn.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/04-strip-away.png b/sanctification-tcg/card-harness/pack-foil-review/04-strip-away.png new file mode 100644 index 0000000..d8bea63 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/04-strip-away.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/05-mouth-open.png b/sanctification-tcg/card-harness/pack-foil-review/05-mouth-open.png new file mode 100644 index 0000000..2c44a25 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/05-mouth-open.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/06-cards-emerging.png b/sanctification-tcg/card-harness/pack-foil-review/06-cards-emerging.png new file mode 100644 index 0000000..0c2d4a7 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/06-cards-emerging.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/07-cards-clear.png b/sanctification-tcg/card-harness/pack-foil-review/07-cards-clear.png new file mode 100644 index 0000000..aa897d0 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/07-cards-clear.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/08-stack-ready.png b/sanctification-tcg/card-harness/pack-foil-review/08-stack-ready.png new file mode 100644 index 0000000..315bde8 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/08-stack-ready.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/09-inspecting-rotated.png b/sanctification-tcg/card-harness/pack-foil-review/09-inspecting-rotated.png new file mode 100644 index 0000000..71d8278 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/09-inspecting-rotated.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/10-mobile-sealed.png b/sanctification-tcg/card-harness/pack-foil-review/10-mobile-sealed.png new file mode 100644 index 0000000..dba41b4 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/10-mobile-sealed.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/11-mobile-partial.png b/sanctification-tcg/card-harness/pack-foil-review/11-mobile-partial.png new file mode 100644 index 0000000..f739bb5 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/11-mobile-partial.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/12-mobile-inspection.png b/sanctification-tcg/card-harness/pack-foil-review/12-mobile-inspection.png new file mode 100644 index 0000000..d110ef3 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/12-mobile-inspection.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/13-mobile-landscape.png b/sanctification-tcg/card-harness/pack-foil-review/13-mobile-landscape.png new file mode 100644 index 0000000..f6345e2 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/13-mobile-landscape.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/14-mobile-default-zoom.png b/sanctification-tcg/card-harness/pack-foil-review/14-mobile-default-zoom.png new file mode 100644 index 0000000..3e92232 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/14-mobile-default-zoom.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/15-detached-curl.png b/sanctification-tcg/card-harness/pack-foil-review/15-detached-curl.png new file mode 100644 index 0000000..93f66f8 Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/15-detached-curl.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/inspect-after.png b/sanctification-tcg/card-harness/pack-foil-review/inspect-after.png new file mode 100644 index 0000000..51e734e Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/inspect-after.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/inspect-before.png b/sanctification-tcg/card-harness/pack-foil-review/inspect-before.png new file mode 100644 index 0000000..51e734e Binary files /dev/null and b/sanctification-tcg/card-harness/pack-foil-review/inspect-before.png differ diff --git a/sanctification-tcg/card-harness/pack-foil-review/lifecycle-validation.json b/sanctification-tcg/card-harness/pack-foil-review/lifecycle-validation.json new file mode 100644 index 0000000..d7a9182 --- /dev/null +++ b/sanctification-tcg/card-harness/pack-foil-review/lifecycle-validation.json @@ -0,0 +1,19 @@ +{ + "checks": [ + "Body press produces bounded visible vertex dent", + "Contact deformation remains inside safe bound", + "Body drag does not accidentally open pack", + "Restart deterministically reconstructs original foil vertices", + "Lost pointer capture retains partial tear", + "Wheel pauses animation while zooming", + "Foil geometry/normals are finite and opaque", + "Attached ribbon shares seam samples and pouch surfaces do not cross", + "Inspect card pixels/pose remain identical after Pack inspection", + "Inspect/Lab selections preserved", + "Delayed pack-specific texture load exercised", + "Async pack completion respects mode ownership after switching away", + "Loaded Pack returns sealed and active", + "No runtime exceptions in lifecycle checks" + ], + "errors": [] +} \ No newline at end of file diff --git a/sanctification-tcg/card-harness/pack-foil-review/validation.json b/sanctification-tcg/card-harness/pack-foil-review/validation.json new file mode 100644 index 0000000..b6be4ab --- /dev/null +++ b/sanctification-tcg/card-harness/pack-foil-review/validation.json @@ -0,0 +1,4332 @@ +{ + "checks": [ + "Mouse drag maps distance to retained partial tear", + "Released partial tear holds without motion", + "Mode switch preserves partial tear", + "Primary resumes partial tear without reset", + "Touch pauses scripted opening without advancing", + "Paused opening remains at exact elapsed pose", + "Skip completes opening only", + "Lift starts separately from reveal 0", + "Lift interruption preserves hidden fronts 0", + "Same card reaches approved z=3 inspection plane 0", + "Reveal event fires once after flip 0", + "Manual rotation retains full clearance 0", + "Reserve stack remains visible face-down behind active card 0", + "Skip advance preserves next unseen card 0", + "Lift starts separately from reveal 1", + "Lift interruption preserves hidden fronts 1", + "Same card reaches approved z=3 inspection plane 1", + "Reveal event fires once after flip 1", + "Manual rotation retains full clearance 1", + "Reserve stack remains visible face-down behind active card 1", + "Skip advance preserves next unseen card 1", + "Lift starts separately from reveal 2", + "Lift interruption preserves hidden fronts 2", + "Same card reaches approved z=3 inspection plane 2", + "Reveal event fires once after flip 2", + "Manual rotation retains full clearance 2", + "Reserve stack remains visible face-down behind active card 2", + "Skip advance preserves next unseen card 2", + "Exact ordered reveal/completion event sequence", + "Complete state emits no duplicate events", + "Card extrusion caps still excluded", + "Native touch cancel preserves partial tear", + "Restart cancels in-flight animation and restores wrapper", + "Completed native touch tear automatically extracts without revealing identities", + "Lab mode pauses animated tear and returns with Continue", + "Reduced-motion opening reaches same stack in shortened transition", + "Reduced-motion lift remains separate 0", + "Reduced-motion flip preserves reveal 0", + "Reduced-motion lift remains separate 1", + "Reduced-motion flip preserves reveal 1", + "Reduced-motion lift remains separate 2", + "Reduced-motion flip preserves reveal 2", + "Reduced-motion retains event-once ordering", + "Mobile Pack entry keeps collapsible header compact", + "Mobile native seam gesture works", + "Mobile pinch zoom does not change partial tear", + "Mobile skip preserves lift/reveal state separation", + "Mobile rotation keeps approved card clearance", + "Landscape header and pack controls do not overlap", + "Real animation pause for visual phase 0.32", + "Real animation pause for visual phase 0.44", + "Real animation pause for visual phase 0.58", + "Real animation pause for visual phase 0.72", + "Real animation pause for visual phase 0.83", + "Every sampled opening frame preserves hidden fronts", + "Real extraction has small authored card stagger", + "All cards clear pouch before lateral wrapper exit", + "No runtime exceptions during browser validation" + ], + "errors": [], + "browser": { + "protocolVersion": "1.3", + "product": "Chrome/152.0.7977.77", + "revision": "@529d9a34b491745086b59458f58a5aae8292adaa", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36", + "jsVersion": "15.2.124.19" + }, + "samples": [ + { + "p": 0.000010887513345435827, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.00015024993382019012, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.00045105987632900135, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.0009112579050199104, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.0015291384060014117, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.0023029957653822766, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.0032251109627154675, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.004304896706618633, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.005535552680503392, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.006906670319244606, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.008433071999428814, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.01010523728117684, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.011921460550595626, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.013867887917244451, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.01595329278584793, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.018189802460053625, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.020563568092878053, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.023072884070430726, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.025716044778817854, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.028474336159142112, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.03137929352463616, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.03439444338994698, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.037573716948984266, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.04083972519564393, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.04424866611643691, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.04777953289733946, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.05140840271626653, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.0551772997833015, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.059039404029842056, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.06303954971931645, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.0671531136259528, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.07137839013586308, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.07568738983760623, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.08015725850992977, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.08470743914630678, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.08933432646707024, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.09406317636986553, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.09892170538222103, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.10388002812711894, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.10893643899066979, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.11405809384036913, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.11930500224811062, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.12464489214608705, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.13007605792040922, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.13556347246285774, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.14117155208861915, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.14686580096230842, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.15264451347002905, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.15847064245107856, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.1644126851174944, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.17039779570486835, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.17653313585327934, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.18267093401877954, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.18891973154847636, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.19524107384148223, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.20159477129897638, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.20805567737970035, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.21458402159531553, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.22113842059089742, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.22779614591600794, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.23447578075643966, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.24125610552735519, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.24809536079021163, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.25499184093112354, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.26194384033619317, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.2689075456351239, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.2759651597787232, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.2830731865580622, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.2901869240835573, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.29743365556840523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3046391477701711, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.311931513010572, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.31922173399737647, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.32659594760533234, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.33400865497830334, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3380188912174203, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3399832592284717, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3474161608627392, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3548823839979837, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.36242551386571414, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3699989668929876, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3776010374659192, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.3852300199706106, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.392838303684959, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.40051585756477665, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4081690512802245, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.41588839487146556, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4236261427515288, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.43133410917205617, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.43910346408532, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4468861166589882, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45066521639864465, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.45393315209374935, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4616896571918931, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4695012142609259, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4773194201601556, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.4850957127338853, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.49292208514105285, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5007031247940064, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5085308820454087, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5163567804840705, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.524179114496093, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5319961784675916, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.539806266784668, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5475609880312492, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5553520754707655, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5631310806274414, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5708962978873888, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5785996655891738, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.586286049618592, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5892907873596875, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.5956140296260987, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6033027646370183, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6110146796623984, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6185648870784662, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6262262291922275, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.017273912038948232, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6337234165612969, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.08139084826276764, + -0.045, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6412821935054376, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.18960967113599422, + -0.04014288696861141, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6488098098029745, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.3362718058197588, + 0.0061093345585442604, + -0.09 + ], + "wrapperX": 0 + }, + { + "p": 0.6563045598400097, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.5162950811119691, + 0.09705102758239172, + -0.08995768422013734 + ], + "wrapperX": 0 + }, + { + "p": 0.6637201725398254, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.7233653070697856, + 0.22667489605548152, + -0.06360153891023695 + ], + "wrapperX": 0 + }, + { + "p": 0.6711442955207101, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.9551095301992528, + 0.3915400492973578, + 0.008533545734789275 + ], + "wrapperX": 0 + }, + { + "p": 0.6785304456126751, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.2055766539803836, + 0.5860507899186088, + 0.12136912712181425 + ], + "wrapperX": 0 + }, + { + "p": 0.6859207851053375, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.471717454210905, + 0.8067659348306122, + 0.2709252530334604 + ], + "wrapperX": 0 + }, + { + "p": 0.693138388172034, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.7424796473160333, + 1.0433621415554917, + 0.44822358264277795 + ], + "wrapperX": 0 + }, + { + "p": 0.7004440024161569, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.023289483255844, + 1.2998517795103415, + 0.6549546745570889 + ], + "wrapperX": 0 + }, + { + "p": 0.7076181247213191, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.3016126976049516, + 1.5643400542914805, + 0.8806275202325278 + ], + "wrapperX": 0 + }, + { + "p": 0.7147463113897586, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.57672793112183, + 1.8355697949980998, + 1.1231512277311013 + ], + "wrapperX": 0 + }, + { + "p": 0.7219118982901298, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.8479156896269022, + 2.112758358778083, + 1.3813898277605472 + ], + "wrapperX": 0 + }, + { + "p": 0.7289847849221326, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.1064599828945934, + 2.386966517366006, + 1.6466338675060723 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7323548164334187, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2253467332636734, + 2.516720627188341, + 1.775520795143178 + ], + "wrapperX": 0 + }, + { + "p": 0.7332374638554551, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.2559494246386507, + 2.550532657573383, + 1.8094687621409165 + ], + "wrapperX": 0 + }, + { + "p": 0.7402267034246953, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.4893267314217815, + 2.814644654268269, + 2.079978725938907 + ], + "wrapperX": 0 + }, + { + "p": 0.7471202370463678, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.7014093584184358, + 3.0662976320748583, + 2.34719858246655 + ], + "wrapperX": 0 + }, + { + "p": 0.7539994222293754, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.8916090409931123, + 3.305149329132856, + 2.610748783573767 + ], + "wrapperX": 0 + }, + { + "p": 0.7608208616582316, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.055614470911271, + 3.5264379096791174, + 2.8655680766049736 + ], + "wrapperX": 0 + }, + { + "p": 0.7675425390787288, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.189932134115533, + 3.7260059014515523, + 3.1069813328364986 + ], + "wrapperX": 0 + }, + { + "p": 0.7742437414431298, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.293614438203063, + 3.9034399508497715, + 3.3348333581400973 + ], + "wrapperX": 0 + }, + { + "p": 0.7808425312541276, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.363073017796671, + 4.054011930358228, + 3.5435415714623013 + ], + "wrapperX": 0 + }, + { + "p": 0.7874558834103378, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.397073183975826, + 4.1777558192227735, + 3.734001310427929 + ], + "wrapperX": 0 + }, + { + "p": 0.793924645129531, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.269505959551081, + 3.899193854459201 + ], + "wrapperX": 0 + }, + { + "p": 0.800326259281346, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.328913225789912, + 4.039250292052438 + ], + "wrapperX": 0 + }, + { + "p": 0.8066969881141255, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.3542284786050445, + 4.152695880158985 + ], + "wrapperX": 0 + }, + { + "p": 0.8129963569213913, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.236704921016515 + ], + "wrapperX": 0 + }, + { + "p": 0.8192226600892518, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.289578112988044 + ], + "wrapperX": 0 + }, + { + "p": 0.8253741920038207, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.309821560428749 + ], + "wrapperX": 0 + }, + { + "p": 0.8314492470511956, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -0.04136085865574733 + }, + { + "p": 0.8374104462524372, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -0.971315725696851 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8403615540047523, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.792421636375141 + }, + { + "p": 0.8408573715476766, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -1.9484083968254151 + }, + { + "p": 0.8466927528908823, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -4.058716507756008 + }, + { + "p": 0.8524809669687635, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -6.377549277425634 + }, + { + "p": 0.8581849114644904, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -8.50038697307132 + }, + { + "p": 0.8638028807641711, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -10.067767435400079 + }, + { + "p": 0.8693003180733142, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.4, + 4.355, + 4.3100000000000005 + ], + "wrapperX": -10.752959591430113 + }, + { + "p": 0.8747740713198324, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.382633986531459, + 4.3376339865314595, + 4.2926339865314596 + ], + "wrapperX": -10.74700324297127 + }, + { + "p": 0.8800921210288709, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.324564993445878, + 4.279564993445878, + 4.234564993445878 + ], + "wrapperX": -10.69443686494809 + }, + { + "p": 0.8853184911993384, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.231116203327605, + 4.186116203327605, + 4.141116203327605 + ], + "wrapperX": -10.609843277800357 + }, + { + "p": 0.8904821421769916, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 4.106746474691138, + 4.061746474691138, + 4.016746474691138 + ], + "wrapperX": -10.49725881881878 + }, + { + "p": 0.8955496047017629, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.956938950444546, + 3.911938950444546, + 3.866938950444546 + ], + "wrapperX": -10.361647048546521 + }, + { + "p": 0.9005191731597603, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.786360337520714, + 3.7413603375207143, + 3.6963603375207144 + ], + "wrapperX": -10.207232456986286 + }, + { + "p": 0.9053891419370969, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.599327130461868, + 3.554327130461868, + 3.5093271304618683 + ], + "wrapperX": -10.037922507859848 + }, + { + "p": 0.9101295554156595, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.4010359556631133, + 3.3560359556631134, + 3.3110359556631135 + ], + "wrapperX": -9.858421395488737 + }, + { + "p": 0.9147958298978234, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 3.192715071513128, + 3.147715071513128, + 3.102715071513128 + ], + "wrapperX": -9.669840988554306 + }, + { + "p": 0.9193303982800745, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.980122779475401, + 2.935122779475401, + 2.890122779475401 + ], + "wrapperX": -9.477393932143324 + }, + { + "p": 0.9237861968033735, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.7636679448822896, + 2.7186679448822897, + 2.6736679448822898 + ], + "wrapperX": -9.281450347528423 + }, + { + "p": 0.928133888003063, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.547290686415783, + 2.5022906864157832, + 2.4572906864157833 + ], + "wrapperX": -9.085576987930004 + }, + { + "p": 0.9323717662652551, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.333360686551755, + 2.288360686551755, + 2.243360686551755 + ], + "wrapperX": -8.89191898482363 + }, + { + "p": 0.9364981259760504, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.123954764430282, + 2.078954764430282, + 2.0339547644302822 + ], + "wrapperX": -8.702356358065018 + }, + { + "p": 0.9404875711082917, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.9220621371331081, + 1.8770621371331082, + 1.832062137133108 + ], + "wrapperX": -8.519595066665396 + }, + { + "p": 0.9443634688362385, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.7279121254260672, + 1.6829121254260673, + 1.6379121254260671 + ], + "wrapperX": -8.343842700965574 + }, + { + "p": 0.948146446131503, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.541665803650159, + 1.496665803650159, + 1.4516658036501588 + ], + "wrapperX": -8.175245071909554 + }, + { + "p": 0.9518326870889374, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.3645209257302893, + 1.3195209257302893, + 1.2745209257302892 + ], + "wrapperX": -8.014886434316466 + }, + { + "p": 0.9553557333668071, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.2003392158286017, + 1.1553392158286018, + 1.1103392158286016 + ], + "wrapperX": -7.866262575547896 + }, + { + "p": 0.9587585013375436, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 1.0475159145077377, + 1.0025159145077378, + 0.9575159145077378 + ], + "wrapperX": -7.727920803095537 + }, + { + "p": 0.9620780933691931, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.9047916115617575, + 0.8597916115617574, + 0.8147916115617575 + ], + "wrapperX": -7.598721048405518 + }, + { + "p": 0.9652337795396371, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.7757497630957496, + 0.7307497630957496, + 0.6857497630957496 + ], + "wrapperX": -7.48190719945041 + }, + { + "p": 0.9682820137926581, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.6579640649804814, + 0.6129640649804814, + 0.5679640649804815 + ], + "wrapperX": -7.375282868566517 + }, + { + "p": 0.9711846067520373, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.5527038765591792, + 0.5077038765591791, + 0.4627038765591792 + ], + "wrapperX": -7.27999713066521 + }, + { + "p": 0.973974832304253, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.45841805329229174, + 0.41341805329229175, + 0.3684180532922917 + ], + "wrapperX": -7.194645827590785 + }, + { + "p": 0.9766331127914012, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.3753664203404094, + 0.3303664203404094, + 0.2853664203404094 + ], + "wrapperX": -7.119464163483797 + }, + { + "p": 0.9791577425995934, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.3030295021031942, + 0.2580295021031942, + 0.21302950210319418 + ], + "wrapperX": -7.053981888227441 + }, + { + "p": 0.9815331152934673, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.24113823406614449, + 0.1961382340661445, + 0.1511382340661445 + ], + "wrapperX": -6.997955433847997 + }, + { + "p": 0.9837861527081023, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.18826025857253864, + 0.14326025857253866, + 0.09826025857253864 + ], + "wrapperX": -6.950088172833141 + }, + { + "p": 0.9858881887827091, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.14428737799663935, + 0.09928737799663935, + 0.05428737799663935 + ], + "wrapperX": -6.910282160359077 + }, + { + "p": 0.9878742500013582, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.10770165902916036, + 0.06270165902916036, + 0.017701659029160363 + ], + "wrapperX": -6.877163302405745 + }, + { + "p": 0.9896953571755737, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.07855511697886271, + 0.03355511697886271, + -0.01144488302113729 + ], + "wrapperX": -6.850778685313686 + }, + { + "p": 0.9913839982752625, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.055420800388818144, + 0.010420800388818145, + -0.03457919961118185 + ], + "wrapperX": -6.82983657547913 + }, + { + "p": 0.9929182664769414, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.03774859346760922, + -0.007251406532390779, + -0.05225140653239078 + ], + "wrapperX": -6.8138389860572675 + }, + { + "p": 0.9943149498266278, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.024507901700040736, + -0.020492098299959263, + -0.06549209829995926 + ], + "wrapperX": -6.801852981620843 + }, + { + "p": 0.9955626626121042, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.015029181514148428, + -0.02997081848585157, + -0.07497081848585158 + ], + "wrapperX": -6.793272464515384 + }, + { + "p": 0.9966535819368261, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.008596663893088509, + -0.03640333610691149, + -0.08140333610691149 + ], + "wrapperX": -6.787449491962527 + }, + { + "p": 0.9976043540348599, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.0044275552610241855, + -0.04057244473897581, + -0.08557244473897581 + ], + "wrapperX": -6.783675447868982 + }, + { + "p": 0.9983949214443568, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.0019956770612889587, + -0.04300432293871104, + -0.08800432293871104 + ], + "wrapperX": -6.781474014333442 + }, + { + "p": 0.9990263618168039, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.0007367303311932361, + -0.04426326966880676, + -0.08926326966880677 + ], + "wrapperX": -6.780334365336468 + }, + { + "p": 0.9995021942107347, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.00019306209125944385, + -0.04480693790874055, + -0.08980693790874056 + ], + "wrapperX": -6.779842215073462 + }, + { + "p": 0.9998320937501664, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0.000022001223686496377, + -0.044977998776313505, + -0.0899779987763135 + ], + "wrapperX": -6.779687363925681 + }, + { + "p": 0.9999826577474291, + "state": "opening", + "fronts": [ + false, + false, + false + ], + "ys": [ + 2.3488734264276447e-7, + -0.04499976511265735, + -0.08999976511265735 + ], + "wrapperX": -6.779667660166348 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + }, + { + "p": 1, + "state": "stackReady", + "fronts": [ + false, + false, + false + ], + "ys": [ + 0, + -0.045, + -0.09 + ], + "wrapperX": -6.779667447536919 + } + ], + "final": { + "state": "stackReady", + "paused": false, + "progress": 1, + "tear": 1, + "zoom": 1, + "card": [ + { + "visible": true, + "front": false, + "uuid": "a02d0ef5-732f-4121-906a-12fd9bf02620", + "position": [ + 0, + 0, + -0.08 + ], + "rotation": [ + -3.141592653589793, + 1.2246467991473532e-16, + -3.141592653589793, + "XYZ" + ] + }, + { + "visible": true, + "front": false, + "uuid": "d19668c9-9742-421d-88de-dedc789b512f", + "position": [ + 0.045, + -0.045, + -0.135 + ], + "rotation": [ + -3.141592653589793, + 1.2246467991473532e-16, + -3.141592653589793, + "XYZ" + ] + }, + { + "visible": true, + "front": false, + "uuid": "33767979-fe89-4895-8305-fbbdaf6c9c28", + "position": [ + 0.09, + -0.09, + -0.19 + ], + "rotation": [ + -3.141592653589793, + 1.2246467991473532e-16, + -3.141592653589793, + "XYZ" + ] + } + ], + "wrapper": false, + "events": [] + }, + "performance": "1280×786 · median 16.7 ms · p95 16.8 ms · worst 17.1 ms" +} \ No newline at end of file diff --git a/sanctification-tcg/card-harness/package-lock.json b/sanctification-tcg/card-harness/package-lock.json new file mode 100644 index 0000000..e1139f8 --- /dev/null +++ b/sanctification-tcg/card-harness/package-lock.json @@ -0,0 +1,955 @@ +{ + "name": "card-harness", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "card-harness", + "version": "0.0.0", + "dependencies": { + "lil-gui": "^0.21.0", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/pngjs": "^6.0.5", + "@types/three": "^0.185.4", + "pngjs": "^7.0.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha1-ezNl4d/cXNlXtFr+kgtKwGx804k=", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha1-US5DGWBT20qZko41sodUmjImJos=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha1-gbwyt5kC/NOHXf/fO+LMuj7415Q=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha1-myCujYpbuXnqaqj6S34R9YxT93U=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha1-EDHuBrUckTS6Wm2jpzEDiIdffdg=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha1-Ec8xBAt3nbqOYdkyrjwO6B75SuA=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha1-ctgFtFm6Sou/wRK8pKHHB0BRauk=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha1-Q7d43QIfUBgqHv20FJzYnP+GyUI=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha1-6fp6RpmZwTRtgnspFUBo0cgzxHQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha1-XmjdbrhXl3RfSU3+jEkMK85CGKY=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha1-Kg78FUN6Vd0sr1jwMY4SnRHZjuY=", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha1-vCsLYyXYquDxocBvsQ1mXIIlhUE=", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha1-5uUCL2E4/cDRTO+sxn3HvF3/ZDA=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha1-yLRiYJgJI3u/YmVJGx541cJg7UY=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha1-qiCHkWR7OwvSOzctXua9XtWYgRg=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha1-oqXxqEVV0Ar+CsD/OHh4vPStpSo=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha1-EWKQPiZO0Q20vor0C6jPE81KC0o=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha1-4/zuCT+7XOdl4a0Ij/TeKIn2+b4=", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha1-7/AkVzXASpKLsZwCa1jCpWRgU50=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-26.4.0.tgz", + "integrity": "sha1-TEygccQiQf5gJ0HQKZnxa05kxGg=", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha1-bewvfrgoRUPKTkI/PAmxGfqTnqM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha1-GTPl/xU6I8dmRIeDMZjWhcIueR4=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/three/-/three-0.185.4.tgz", + "integrity": "sha1-rX8lIV5AHBwgOKwVDnozYTZrOKc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha1-c01dkNrcWAmlPkInJsYDN/ovSkQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha1-vCfY6zA0PU1RKrsDSAICzmXYJfw=", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha1-wIhn1xp5OFxuGQIU/XL+8+X5Xws=", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha1-mmhB+IrlD8g1ApA4krQa9BvCuQc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha1-wPLDHAv9GfpN0/GOlXofGhUgl9Y=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha1-ywcFllrLU4xmg5Sc5pJfs833w2E=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha1-djU4gosmurJoDa2vzITueLDrUCs=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha1-aGLjF2ozGu297B7TUrTX0N0HhN4=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha1-xqOi7RUUHa9r3CYokw+OOb30c6o=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha1-f6EzSXH8goRfmCffbvigsgkUusY=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha1-i5J4YuqMK7xoMaRlCSRLUNmTblU=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha1-DFJbsHff2UQEwFnP5C2teX6Wrq8=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha1-hQ7hED2smJz6tQ46wi0aaeOU5j0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha1-40OuFS7tNgncbhGUnRo785oclG8=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lil-gui": { + "version": "0.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lil-gui/-/lil-gui-0.21.0.tgz", + "integrity": "sha1-xcvQdLafDiruO6E+B6/+Le8hrQk=", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha1-IMfQUNWb3FcxVIFPajfUfYmQjMo=", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha1-9mot4Rmf/eD88hyKXxMQaxwIGRM=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha1-YxM2ADTMs2s9xh7L3/eBIfkP4h8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha1-qLdEYCDrvGrHOdtsVBWmXRcJDiY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha1-bnUTV4DH4Q3zQzvyJmxVLTXIxiA=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha1-Al9cEZdcxwEp6px2xU813TWIF4Y=", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/three/-/three-0.185.1.tgz", + "integrity": "sha1-Y+niQaF7EB4hGWUSGgF7S02AVK4=", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha1-kCUdwAeRbpcnhsuU100VsYVXfSE=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha1-ROn8nzJEZIzeo15Pm7LWgelBCAk=", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-8.2.2.tgz", + "integrity": "sha1-OZrvrTZWFFFFvhENE3oH6lu1UBQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/sanctification-tcg/card-harness/package.json b/sanctification-tcg/card-harness/package.json new file mode 100644 index 0000000..cfc2545 --- /dev/null +++ b/sanctification-tcg/card-harness/package.json @@ -0,0 +1,26 @@ +{ + "name": "card-harness", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "assets": "node scripts/generate-reference-assets.mjs", + "export:blender": "node scripts/export-card-mesh.mjs", + "predev": "npm run assets", + "dev": "vite --host 0.0.0.0", + "prebuild": "npm run assets", + "build": "tsc && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "devDependencies": { + "@types/pngjs": "^6.0.5", + "@types/three": "^0.185.4", + "pngjs": "^7.0.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + }, + "dependencies": { + "lil-gui": "^0.21.0", + "three": "^0.185.1" + } +} diff --git a/sanctification-tcg/card-harness/procedural-card-check.png b/sanctification-tcg/card-harness/procedural-card-check.png new file mode 100644 index 0000000..8aaf38c Binary files /dev/null and b/sanctification-tcg/card-harness/procedural-card-check.png differ diff --git a/sanctification-tcg/card-harness/public/favicon.svg b/sanctification-tcg/card-harness/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/sanctification-tcg/card-harness/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sanctification-tcg/card-harness/public/icons.svg b/sanctification-tcg/card-harness/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/sanctification-tcg/card-harness/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sanctification-tcg/card-harness/public/reference/card-back.png b/sanctification-tcg/card-harness/public/reference/card-back.png new file mode 100644 index 0000000..caa4df8 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/card-back.png differ diff --git a/sanctification-tcg/card-harness/public/reference/david-finish-mask.png b/sanctification-tcg/card-harness/public/reference/david-finish-mask.png new file mode 100644 index 0000000..0e92846 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/david-finish-mask.png differ diff --git a/sanctification-tcg/card-harness/public/reference/david-front.png b/sanctification-tcg/card-harness/public/reference/david-front.png new file mode 100644 index 0000000..9eb7cd0 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/david-front.png differ diff --git a/sanctification-tcg/card-harness/public/reference/linen-normal.png b/sanctification-tcg/card-harness/public/reference/linen-normal.png new file mode 100644 index 0000000..cead946 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/linen-normal.png differ diff --git a/sanctification-tcg/card-harness/public/reference/manifest.json b/sanctification-tcg/card-harness/public/reference/manifest.json new file mode 100644 index 0000000..44de0dd --- /dev/null +++ b/sanctification-tcg/card-harness/public/reference/manifest.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "referenceRevision": "runtime-look-v4-2026-09-07", + "sourceMaterialReference": "blender_prototype/premium_finishes.py", + "genericMaterialReference": "blender_prototype/card_finish_material.py", + "geometry": { + "source": "runtime-procedural", + "widthMeters": 0.063, + "heightMeters": 0.0882, + "thicknessMeters": 0.0004, + "cornerRadiusMeters": 0.0028 + }, + "fixtures": { + "david": { + "artwork": "david-front.png", + "finishMask": "david-finish-mask.png", + "maskRule": "Premium v3 saturation/value and title-panel protection; face, hand, and lamb ellipse cutouts removed" + }, + "timothy": { + "artwork": "timothy-front.png", + "finishMask": "timothy-finish-mask.png", + "maskRule": "Artwork-independent saturation/value coverage" + } + }, + "normalMap": "linen-normal.png", + "referenceRenders": [ + "renders/david_foil_closeup.png", + "renders/david_foil_reflection.png", + "renders/david_holographic_head_on.png", + "renders/david_holographic_spectrum_angle.png", + "renders/david_holographic_opposite_angle.png", + "renders/card_three_quarter_thickness.png" + ], + "hashes": { + "david-front.png": "b1fcd29e82db42532ffdb8c5f5ff93a8a76666e48ef3a6514d85167fee5cb1be", + "david-finish-mask.png": "0d36e94b09251ec69f169ca37d618f9e0ce8aa4986a8d195e202e7951d8fb384", + "timothy-front.png": "6d1206ee8685af5d0e9a1cb774f845617da9a1011c13f9e7797ce2a3bbce17fb", + "timothy-finish-mask.png": "98619c47565712e0ffecc87742e0bc1f78577c4ed2195babd4ff2279f5bd9cf7", + "card-back.png": "837ee8dbad172db3ba64e97f29cb9d3580ea888fcd192859de217a16e62bd3e6", + "linen-normal.png": "ced7e644fe7518b17db1d3651af6bd58680e1adff64eb50a081ccbd874071c13", + "renders/david_foil_closeup.png": "cca86824f633c22e540bb8d57683b2d704f08aa8de2c9aff7087830b687bf6ef", + "renders/david_foil_reflection.png": "925b8e8c754fa695b975f421a52ea34c07e48f490a3388993972fc69bdf9f43f", + "renders/david_holographic_head_on.png": "fece3e5243917f3ee5e58ef04439cf6f657dc748927202cd4a137fdeb0934bc6", + "renders/david_holographic_spectrum_angle.png": "a445c9bb8d988bb42d16cd3ea99aff95594effbdb47e85198afd676e15bdcd98", + "renders/david_holographic_opposite_angle.png": "7fdd450d1351ce8014d3f552e29e162937bd2ea8a7f72bc4f819922b59af7efd", + "renders/card_three_quarter_thickness.png": "7a00e3188c24e2a02b9b9c5bf95e07e34ef3b9ebb355ca338e11c3065f0db9de" + } +} \ No newline at end of file diff --git a/sanctification-tcg/card-harness/public/reference/renders/card_three_quarter_thickness.png b/sanctification-tcg/card-harness/public/reference/renders/card_three_quarter_thickness.png new file mode 100644 index 0000000..dc683fe Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/renders/card_three_quarter_thickness.png differ diff --git a/sanctification-tcg/card-harness/public/reference/renders/david_foil_closeup.png b/sanctification-tcg/card-harness/public/reference/renders/david_foil_closeup.png new file mode 100644 index 0000000..d5791db Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/renders/david_foil_closeup.png differ diff --git a/sanctification-tcg/card-harness/public/reference/renders/david_foil_reflection.png b/sanctification-tcg/card-harness/public/reference/renders/david_foil_reflection.png new file mode 100644 index 0000000..5fd5d9e Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/renders/david_foil_reflection.png differ diff --git a/sanctification-tcg/card-harness/public/reference/renders/david_holographic_head_on.png b/sanctification-tcg/card-harness/public/reference/renders/david_holographic_head_on.png new file mode 100644 index 0000000..8b37d9b Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/renders/david_holographic_head_on.png differ diff --git a/sanctification-tcg/card-harness/public/reference/renders/david_holographic_opposite_angle.png b/sanctification-tcg/card-harness/public/reference/renders/david_holographic_opposite_angle.png new file mode 100644 index 0000000..fa8da74 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/renders/david_holographic_opposite_angle.png differ diff --git a/sanctification-tcg/card-harness/public/reference/renders/david_holographic_spectrum_angle.png b/sanctification-tcg/card-harness/public/reference/renders/david_holographic_spectrum_angle.png new file mode 100644 index 0000000..2e12493 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/renders/david_holographic_spectrum_angle.png differ diff --git a/sanctification-tcg/card-harness/public/reference/timothy-finish-mask.png b/sanctification-tcg/card-harness/public/reference/timothy-finish-mask.png new file mode 100644 index 0000000..f4be62f Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/timothy-finish-mask.png differ diff --git a/sanctification-tcg/card-harness/public/reference/timothy-front.png b/sanctification-tcg/card-harness/public/reference/timothy-front.png new file mode 100644 index 0000000..a6e3b95 Binary files /dev/null and b/sanctification-tcg/card-harness/public/reference/timothy-front.png differ diff --git a/sanctification-tcg/card-harness/scripts/export-card-mesh.mjs b/sanctification-tcg/card-harness/scripts/export-card-mesh.mjs new file mode 100644 index 0000000..1c0fa6c --- /dev/null +++ b/sanctification-tcg/card-harness/scripts/export-card-mesh.mjs @@ -0,0 +1,73 @@ +import { access, mkdir } from 'node:fs/promises' +import { constants } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' + +const here = dirname(fileURLToPath(import.meta.url)) +const harnessRoot = resolve(here, '..') +const projectRoot = resolve(harnessRoot, '..') +const blendFile = resolve(projectRoot, 'blender_prototype', 'sanctification_card_material_prototype.blend') +const pythonScript = resolve(here, 'export_card_mesh.py') +const outputFile = resolve(harnessRoot, 'public', 'reference', 'card-mesh.glb') + +const explicit = process.env.BLENDER_EXECUTABLE +const platformCandidates = process.platform === 'win32' + ? [ + 'C:\\Program Files\\Blender Foundation\\Blender 5.2\\blender.exe', + 'C:\\Program Files\\Blender Foundation\\Blender 5.1\\blender.exe', + 'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe', + ] + : [ + '/usr/bin/blender', + '/usr/local/bin/blender', + '/snap/bin/blender', + ] + +async function exists(path) { + try { + await access(path, constants.X_OK) + return true + } catch { + return false + } +} + +let blender = explicit +if (blender && !(await exists(blender))) { + throw new Error(`BLENDER_EXECUTABLE does not exist or is not executable: ${blender}`) +} +if (!blender) { + for (const candidate of platformCandidates) { + if (await exists(candidate)) { + blender = candidate + break + } + } +} +if (!blender) blender = 'blender' + +await mkdir(dirname(outputFile), { recursive: true }) + +const args = [ + '--background', + blendFile, + '--python', + pythonScript, + '--', + '--output', + outputFile, +] + +console.log(`Exporting card mesh with ${blender}`) +const child = spawn(blender, args, { stdio: 'inherit', shell: false }) +const exitCode = await new Promise((resolveExit, reject) => { + child.once('error', reject) + child.once('exit', (code) => resolveExit(code ?? 1)) +}) + +if (exitCode !== 0) { + throw new Error(`Blender export failed with exit code ${exitCode}`) +} + +console.log(`Card mesh exported to ${outputFile}`) diff --git a/sanctification-tcg/card-harness/scripts/export_card_mesh.py b/sanctification-tcg/card-harness/scripts/export_card_mesh.py new file mode 100644 index 0000000..52b1c6f --- /dev/null +++ b/sanctification-tcg/card-harness/scripts/export_card_mesh.py @@ -0,0 +1,67 @@ +import argparse +import sys +from pathlib import Path + +import bpy + + +def script_args(): + separator = sys.argv.index("--") if "--" in sys.argv else len(sys.argv) + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + return parser.parse_args(sys.argv[separator + 1:]) + + +def slot_material(name, color): + material = bpy.data.materials.get(name) or bpy.data.materials.new(name) + material.diffuse_color = (*color, 1.0) + return material + + +args = script_args() +output = Path(args.output).resolve() +output.parent.mkdir(parents=True, exist_ok=True) + +source = bpy.data.objects.get("CARD_David_Foil") +if source is None: + raise RuntimeError("Expected CARD_David_Foil in the Blender prototype") + +bpy.ops.object.select_all(action="DESELECT") +export_object = source.copy() +export_object.data = source.data.copy() +export_object.name = "SanctificationCardMesh" +export_object.data.name = "SanctificationCardMesh" +export_object.animation_data_clear() +export_object.location = (0, 0, 0) +export_object.rotation_euler = (0, 0, 0) +export_object.scale = (1, 1, 1) +bpy.context.scene.collection.objects.link(export_object) + +material_indices = [polygon.material_index for polygon in export_object.data.polygons] +export_object.data.materials.clear() +export_object.data.materials.append(slot_material("SLOT_FRONT", (0.8, 0.8, 0.8))) +export_object.data.materials.append(slot_material("SLOT_BACK", (0.25, 0.25, 0.25))) +export_object.data.materials.append(slot_material("SLOT_EDGE", (0.5, 0.42, 0.28))) +for polygon, material_index in zip(export_object.data.polygons, material_indices): + polygon.material_index = material_index + +export_object.select_set(True) +bpy.context.view_layer.objects.active = export_object + +bpy.ops.export_scene.gltf( + filepath=str(output), + export_format="GLB", + use_selection=True, + export_apply=True, + export_materials="EXPORT", + export_texcoords=True, + export_normals=True, + export_yup=True, +) + +print({ + "output": str(output), + "object": export_object.name, + "mesh": export_object.data.name, + "materials": [material.name for material in export_object.data.materials], +}) diff --git a/sanctification-tcg/card-harness/scripts/generate-reference-assets.mjs b/sanctification-tcg/card-harness/scripts/generate-reference-assets.mjs new file mode 100644 index 0000000..164a88f --- /dev/null +++ b/sanctification-tcg/card-harness/scripts/generate-reference-assets.mjs @@ -0,0 +1,170 @@ +import { createHash } from 'node:crypto' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PNG } from 'pngjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const harnessRoot = resolve(here, '..') +const projectRoot = resolve(harnessRoot, '..') +const outputRoot = resolve(harnessRoot, 'public', 'reference') +const renderRoot = resolve(projectRoot, 'blender_prototype', 'renders') + +await mkdir(resolve(outputRoot, 'renders'), { recursive: true }) + +const sources = { + david: resolve(projectRoot, 'legendary.png'), + timothy: resolve(projectRoot, 'common.png'), + back: resolve(projectRoot, 'card-back.png'), +} + +await Promise.all([ + copyFile(sources.david, resolve(outputRoot, 'david-front.png')), + copyFile(sources.timothy, resolve(outputRoot, 'timothy-front.png')), + copyFile(sources.back, resolve(outputRoot, 'card-back.png')), +]) + +const referenceRenders = [ + 'david_foil_closeup.png', + 'david_foil_reflection.png', + 'david_holographic_head_on.png', + 'david_holographic_spectrum_angle.png', + 'david_holographic_opposite_angle.png', + 'card_three_quarter_thickness.png', +] + +await Promise.all(referenceRenders.map((name) => + copyFile(resolve(renderRoot, name), resolve(outputRoot, 'renders', name)), +)) + +const smoothstep = (edge0, edge1, value) => { + const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))) + return t * t * (3 - 2 * t) +} + +const imageHsv = (r, g, b) => { + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + return { + saturation: max === 0 ? 0 : (max - min) / max, + value: max, + } +} + +async function generateMask(sourcePath, outputPath, kind) { + const source = PNG.sync.read(await readFile(sourcePath)) + const mask = new PNG({ width: source.width, height: source.height }) + + for (let y = 0; y < source.height; y += 1) { + for (let x = 0; x < source.width; x += 1) { + const index = (y * source.width + x) * 4 + const { saturation, value } = imageHsv( + source.data[index] / 255, + source.data[index + 1] / 255, + source.data[index + 2] / 255, + ) + let coverage + + if (kind === 'david-no-cutouts') { + const u = x / (source.width - 1) + const v = 1 - y / (source.height - 1) + coverage = smoothstep(0.18, 0.62, saturation) * smoothstep(0.015, 0.16, value) + const side = smoothstep(0.34, 0.41, Math.abs(u - 0.5)) + const above = smoothstep(0.245, 0.29, v) + const below = 1 - smoothstep(0.07, 0.105, v) + coverage *= Math.max(side, above, below) + } else { + coverage = smoothstep(0.08, 0.58, saturation) * smoothstep(0.015, 0.16, value) + } + + const byte = Math.round(Math.max(0, Math.min(1, coverage)) * 255) + mask.data[index] = byte + mask.data[index + 1] = byte + mask.data[index + 2] = byte + mask.data[index + 3] = 255 + } + } + + await writeFile(outputPath, PNG.sync.write(mask)) +} + +async function generateLinenNormal(outputPath) { + const size = 512 + const png = new PNG({ width: size, height: size }) + const heightAt = (x, y) => { + const weave = Math.sin(x * 0.27) * 0.55 + Math.sin(y * 0.31) * 0.45 + const cross = Math.sin((x + y) * 0.075) * 0.16 + return weave + cross + } + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const dx = heightAt(x + 1, y) - heightAt(x - 1, y) + const dy = heightAt(x, y + 1) - heightAt(x, y - 1) + const nx = -dx * 0.22 + const ny = -dy * 0.22 + const length = Math.hypot(nx, ny, 1) + const index = (y * size + x) * 4 + png.data[index] = Math.round((nx / length * 0.5 + 0.5) * 255) + png.data[index + 1] = Math.round((ny / length * 0.5 + 0.5) * 255) + png.data[index + 2] = Math.round((1 / length * 0.5 + 0.5) * 255) + png.data[index + 3] = 255 + } + } + + await writeFile(outputPath, PNG.sync.write(png)) +} + +await Promise.all([ + generateMask(sources.david, resolve(outputRoot, 'david-finish-mask.png'), 'david-no-cutouts'), + generateMask(sources.timothy, resolve(outputRoot, 'timothy-finish-mask.png'), 'automatic'), + generateLinenNormal(resolve(outputRoot, 'linen-normal.png')), +]) + +const generatedFiles = [ + 'david-front.png', + 'david-finish-mask.png', + 'timothy-front.png', + 'timothy-finish-mask.png', + 'card-back.png', + 'linen-normal.png', + ...referenceRenders.map((name) => `renders/${name}`), +] + +const hashes = {} +for (const name of generatedFiles) { + hashes[name] = createHash('sha256') + .update(await readFile(resolve(outputRoot, name))) + .digest('hex') +} + +await writeFile(resolve(outputRoot, 'manifest.json'), JSON.stringify({ + schemaVersion: 2, + referenceRevision: 'runtime-look-v4-2026-09-07', + sourceMaterialReference: 'blender_prototype/premium_finishes.py', + genericMaterialReference: 'blender_prototype/card_finish_material.py', + geometry: { + source: 'runtime-procedural', + widthMeters: 0.063, + heightMeters: 0.0882, + thicknessMeters: 0.0004, + cornerRadiusMeters: 0.0028, + }, + fixtures: { + david: { + artwork: 'david-front.png', + finishMask: 'david-finish-mask.png', + maskRule: 'Premium v3 saturation/value and title-panel protection; face, hand, and lamb ellipse cutouts removed', + }, + timothy: { + artwork: 'timothy-front.png', + finishMask: 'timothy-finish-mask.png', + maskRule: 'Artwork-independent saturation/value coverage', + }, + }, + normalMap: 'linen-normal.png', + referenceRenders: referenceRenders.map((name) => `renders/${name}`), + hashes, +}, null, 2)) + +console.log(`Reference assets ready in ${outputRoot}`) diff --git a/sanctification-tcg/card-harness/src/assets/hero.png b/sanctification-tcg/card-harness/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/sanctification-tcg/card-harness/src/assets/hero.png differ diff --git a/sanctification-tcg/card-harness/src/assets/typescript.svg b/sanctification-tcg/card-harness/src/assets/typescript.svg new file mode 100644 index 0000000..6c9d69c --- /dev/null +++ b/sanctification-tcg/card-harness/src/assets/typescript.svg @@ -0,0 +1 @@ + diff --git a/sanctification-tcg/card-harness/src/assets/vite.svg b/sanctification-tcg/card-harness/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/sanctification-tcg/card-harness/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/sanctification-tcg/card-harness/src/cardGeometry.ts b/sanctification-tcg/card-harness/src/cardGeometry.ts new file mode 100644 index 0000000..5057714 --- /dev/null +++ b/sanctification-tcg/card-harness/src/cardGeometry.ts @@ -0,0 +1,93 @@ +import * as THREE from 'three' + +export const cardDimensions = { + width: 0.063, + height: 0.0882, + thickness: 0.0004, + cornerRadius: 0.0028, +} as const + +const sceneHeight = 4 +const sceneScale = sceneHeight / cardDimensions.height + +export const cardSceneDimensions = { + width: cardDimensions.width * sceneScale, + height: cardDimensions.height * sceneScale, + thickness: cardDimensions.thickness * sceneScale, + cornerRadius: cardDimensions.cornerRadius * sceneScale, +} as const + +function createRoundedRectangle(width: number, height: number, radius: number) { + const halfWidth = width / 2 + const halfHeight = height / 2 + const shape = new THREE.Shape() + + shape.moveTo(-halfWidth + radius, -halfHeight) + shape.lineTo(halfWidth - radius, -halfHeight) + shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + radius) + shape.lineTo(halfWidth, halfHeight - radius) + shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - radius, halfHeight) + shape.lineTo(-halfWidth + radius, halfHeight) + shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - radius) + shape.lineTo(-halfWidth, -halfHeight + radius) + shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + radius, -halfHeight) + + return shape +} + +export function createCardGeometry( + frontMaterial: THREE.Material, + backMaterial: THREE.Material, + edgeMaterial: THREE.Material, +) { + const { width, height, thickness, cornerRadius } = cardSceneDimensions + const shape = createRoundedRectangle(width, height, cornerRadius) + + const bodyGeometry = new THREE.ExtrudeGeometry(shape, { + curveSegments: 16, + depth: thickness, + steps: 1, + bevelEnabled: false, + }) + bodyGeometry.translate(0, 0, -thickness / 2) + // Extrusion caps would duplicate the dedicated faces and z-fight at Pack's camera distances. + const sideWalls = bodyGeometry.groups.find((group) => group.materialIndex === 1) + if (!sideWalls) throw new Error('Card extrusion is missing its side-wall geometry') + bodyGeometry.setDrawRange(sideWalls.start, sideWalls.count) + + const faceGeometry = new THREE.ShapeGeometry(shape, 16) + const positions = faceGeometry.getAttribute('position') + const uvs = faceGeometry.getAttribute('uv') + for (let index = 0; index < positions.count; index += 1) { + uvs.setXY( + index, + positions.getX(index) / width + 0.5, + positions.getY(index) / height + 0.5, + ) + } + uvs.needsUpdate = true + const faceOffset = thickness / 2 + + const body = new THREE.Mesh(bodyGeometry, edgeMaterial) + body.name = 'CARD_EDGE' + + const front = new THREE.Mesh(faceGeometry, frontMaterial) + front.name = 'CARD_FRONT' + front.position.z = faceOffset + + const back = new THREE.Mesh(faceGeometry.clone(), backMaterial) + back.name = 'CARD_BACK' + back.position.z = -faceOffset + back.rotation.y = Math.PI + + const card = new THREE.Group() + card.name = 'CARD_PROCEDURAL' + card.add(body, front, back) + + for (const mesh of [body, front, back]) { + mesh.castShadow = true + mesh.receiveShadow = true + } + + return card +} diff --git a/sanctification-tcg/card-harness/src/cardMaterial.ts b/sanctification-tcg/card-harness/src/cardMaterial.ts new file mode 100644 index 0000000..75c16c2 --- /dev/null +++ b/sanctification-tcg/card-harness/src/cardMaterial.ts @@ -0,0 +1,493 @@ +import * as THREE from 'three' + +export type FinishName = 'Printed ink' | 'Foil' | 'Holographic' +export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood' + +export interface MaterialControls { + finish: FinishName + substrate: SubstrateName + finishStrength: number + roughness: number + normalStrength: number + environmentIntensity: number + condition: number + imperfectionSeed: number +} + +export function applyEdgeMaterialControls( + material: THREE.MeshPhysicalMaterial, + controls: Pick, +) { + const edgeLooks: Record = { + Paper: { color: '#9c8c69', wornColor: '#d0c29e', roughness: 0.62, metalness: 0.02, clearcoat: 0.02 }, + Linen: { color: '#a89772', wornColor: '#c5b58f', roughness: 0.78, metalness: 0, clearcoat: 0 }, + Plastic: { color: '#d4cab0', wornColor: '#c7ccd2', roughness: 0.24, metalness: 0, clearcoat: 0.72 }, + Metal: { color: '#a8adb7', wornColor: '#d0d5dc', roughness: 0.2, metalness: 0.86, clearcoat: 0.18 }, + Wood: { color: '#6b3f21', wornColor: '#a87847', roughness: 0.56, metalness: 0, clearcoat: 0.08 }, + } + const look = edgeLooks[controls.substrate] + material.color.set(look.color) + material.color.lerp( + new THREE.Color(look.wornColor), + Math.pow(1 - controls.condition, 1.15) * 0.38, + ) + material.roughness = look.roughness + material.metalness = look.metalness + material.clearcoat = look.clearcoat + material.needsUpdate = true +} + +export function createStudioCubeTexture( + colors = ['#d7b77c', '#18243a', '#f4ead4', '#111722', '#6b7f9f', '#231718'], +) { + const canvases = colors.map((color, index) => { + const canvas = document.createElement('canvas') + canvas.width = 128 + canvas.height = 128 + const context = canvas.getContext('2d')! + const gradient = context.createLinearGradient(0, 0, 128, 128) + gradient.addColorStop(0, color) + gradient.addColorStop(0.48, index % 2 ? '#10151f' : '#fff4d6') + gradient.addColorStop(1, '#05070b') + context.fillStyle = gradient + context.fillRect(0, 0, 128, 128) + return canvas + }) + const texture = new THREE.CubeTexture(canvases) + texture.colorSpace = THREE.SRGBColorSpace + texture.needsUpdate = true + return texture +} + +const vertexShader = ` + varying vec2 vUv; + varying vec3 vWorldPosition; + varying vec3 vWorldNormal; + varying vec3 vWorldTangent; + varying vec3 vWorldBitangent; + + void main() { + vUv = uv; + vec4 worldPosition = modelMatrix * vec4(position, 1.0); + mat3 modelRotation = mat3(modelMatrix); + vWorldPosition = worldPosition.xyz; + vWorldNormal = normalize(modelRotation * normal); + vWorldTangent = normalize(modelRotation * vec3(1.0, 0.0, 0.0)); + vWorldBitangent = normalize(modelRotation * vec3(0.0, 1.0, 0.0)); + gl_Position = projectionMatrix * viewMatrix * worldPosition; + } +` + +const fragmentShader = ` + uniform sampler2D artwork; + uniform sampler2D finishMask; + uniform sampler2D normalMap; + uniform samplerCube studioEnvironment; + uniform vec3 lightPosition; + uniform vec3 lightColor; + uniform float lightIntensity; + uniform float environmentIntensity; + uniform float finishStrength; + uniform float roughness; + uniform float normalStrength; + uniform float condition; + uniform float imperfectionSeed; + uniform int finishMode; + uniform int substrateMode; + uniform int lightMode; + + varying vec2 vUv; + varying vec3 vWorldPosition; + varying vec3 vWorldNormal; + varying vec3 vWorldTangent; + varying vec3 vWorldBitangent; + + vec3 spectrum(float phase) { + return 0.52 + 0.48 * cos(6.2831853 * (phase + vec3(0.00, 0.33, 0.67))); + } + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float fiberNoise(vec2 p) { + vec2 cell = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(cell), hash(cell + vec2(1.0, 0.0)), f.x), + mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), f.x), + f.y + ); + } + + float filteredWave(float phase) { + float visibility = 1.0 - smoothstep(0.7, 3.0, fwidth(phase)); + return sin(phase) * visibility; + } + + float woodGrain(vec2 uv) { + float warp = fiberNoise(uv * vec2(4.0, 2.5)); + float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5; + return 0.5 + 0.5 * filteredWave(phase); + } + + float scratchLine(vec2 uv, float index) { + float seed = imperfectionSeed * 0.001 + index * 19.17; + float angle = hash(vec2(seed, 1.3)) * 6.2831853; + vec2 direction = vec2(cos(angle), sin(angle)); + vec2 perpendicular = vec2(-direction.y, direction.x); + vec2 center = vec2( + hash(vec2(seed, 4.7)), + hash(vec2(seed, 8.9)) + ); + vec2 offset = uv - center; + float along = dot(offset, direction); + float across = abs(dot(offset, perpendicular)); + float halfLength = mix(0.08, 0.32, hash(vec2(seed, 13.1))); + float width = mix(0.00045, 0.0014, hash(vec2(seed, 17.3))); + float line = 1.0 - smoothstep(width, width * 2.4, across); + float ends = smoothstep(-halfLength, -halfLength * 0.78, along) * + (1.0 - smoothstep(halfLength * 0.78, halfLength, along)); + return line * ends; + } + + float scuffMark(vec2 uv, float index) { + float seed = imperfectionSeed * 0.001 + index * 31.73; + vec2 center = vec2( + hash(vec2(seed, 2.9)), + hash(vec2(seed, 7.1)) + ); + vec2 scale = vec2( + mix(4.0, 8.0, hash(vec2(seed, 11.7))), + mix(7.0, 14.0, hash(vec2(seed, 15.9))) + ); + float distanceFromCenter = length((uv - center) * scale); + float irregularity = fiberNoise(uv * 38.0 + seed) * 0.24; + return 1.0 - smoothstep(0.34 + irregularity, 0.62 + irregularity, distanceFromCenter); + } + + vec3 applyWearColor( + vec3 color, + float edgeWear, + float scratchWear, + float scuffWear + ) { + vec3 wearColor = vec3(0.82, 0.76, 0.62); + if (substrateMode == 1) wearColor = vec3(0.76, 0.70, 0.57); + if (substrateMode == 2) wearColor = vec3(0.78, 0.80, 0.82); + if (substrateMode == 3) wearColor = vec3(0.72, 0.75, 0.80); + if (substrateMode == 4) wearColor = vec3(0.62, 0.39, 0.20); + float abrasion = clamp( + edgeWear * 0.55 + scratchWear * 0.30 + scuffWear * 0.22, + 0.0, + 0.62 + ); + return mix(color, wearColor, abrasion); + } + + // Screen-space derivatives keep relief aligned to the face UVs rather than + // assuming that the card's local X/Y axes are always the surface tangents. + vec3 reliefNormal(vec3 baseNormal, float height) { + vec3 dx = dFdx(vWorldPosition); + vec3 dy = dFdy(vWorldPosition); + vec3 r1 = cross(dy, baseNormal); + vec3 r2 = cross(baseNormal, dx); + float determinant = dot(dx, r1); + vec3 gradient = sign(determinant) * (dFdx(height) * r1 + dFdy(height) * r2); + return normalize(abs(determinant) * baseNormal - gradient); + } + + void main() { + vec2 artworkUv = vUv; + vec4 artworkSample = texture2D(artwork, artworkUv); + float mask = texture2D(finishMask, artworkUv).r; + float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15); + float edgeDistance = min( + min(artworkUv.x, 1.0 - artworkUv.x), + min(artworkUv.y, 1.0 - artworkUv.y) + ); + float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007); + float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise); + float edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount; + float scratchWear = max( + scratchLine(artworkUv, 1.0), + max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0)) + ) * smoothstep(0.04, 0.48, wearAmount); + float scuffWear = max( + scuffMark(artworkUv, 1.0), + scuffMark(artworkUv, 2.0) + ) * smoothstep(0.18, 0.82, wearAmount); + float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55)); + mask *= 1.0 - wearDamage * 0.82; + vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0; + float detail = normalStrength / 0.14; + float surfaceHeight = 0.0; + float surfaceShade = 1.0; + float metalEtchDepth = 0.0; + float metalEtchExposure = 1.0; + if (substrateMode == 0) { + vec2 fiberUv = artworkUv * vec2(230.0, 322.0); + float visible = 1.0 - smoothstep(0.4, 1.2, max(fwidth(fiberUv.x), fwidth(fiberUv.y))); + float fibers = (fiberNoise(fiberUv) - 0.5) * visible; + surfaceHeight = fibers * 0.0014 * detail; + surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5); + } + if (substrateMode == 1) { + vec2 thread = artworkUv * vec2(52.0, 73.0) * 6.2831853; + float warp = filteredWave(thread.x); + float weft = filteredWave(thread.y); + float overUnder = filteredWave(thread.x * 0.5) * filteredWave(thread.y * 0.5); + float weave = mix(warp, weft, 0.5 + 0.5 * overUnder); + surfaceHeight = weave * 0.005 * detail; + surfaceShade = 1.0 - (1.0 - weave) * 0.055 * min(detail, 1.5); + } + if (substrateMode == 3) { + float artworkLuminance = dot(artworkSample.rgb, vec3(0.2126, 0.7152, 0.0722)); + metalEtchDepth = smoothstep(0.08, 0.92, 1.0 - artworkLuminance); + if (finishMode == 1) { + metalEtchExposure = 1.0 - mask * 0.25; + } + surfaceHeight = -metalEtchDepth * metalEtchExposure * 0.0065 * min(detail, 1.5); + if (finishMode == 1) { + surfaceHeight += mask * 0.0005 * min(detail, 1.5); + } + surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035; + } + if (substrateMode == 4) { + float grain = woodGrain(artworkUv); + surfaceHeight = grain * 0.007 * detail; + surfaceShade = 0.88 + grain * 0.12; + } + surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007; + float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12; + vec3 normal = normalize( + vWorldNormal + + vWorldTangent * normalSample.x * normalStrength * substrateNormalScale + + vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale + ); + normal = reliefNormal(normal, surfaceHeight); + + vec3 viewDirection = normalize(cameraPosition - vWorldPosition); + vec3 lightVector = lightPosition - vWorldPosition; + float lightDistance = length(lightVector); + vec3 lightDirection = lightVector / max(lightDistance, 0.0001); + float attenuation = 1.0 / (1.0 + 0.032 * lightDistance * lightDistance); + if (lightMode == 1) { + lightDirection = normalize(lightPosition); + attenuation = 0.52; + } + if (lightMode == 2) { + vec3 spotForward = normalize(-lightPosition); + vec3 lightToSurface = normalize(vWorldPosition - lightPosition); + float spotAngle = dot(lightToSurface, spotForward); + attenuation *= smoothstep(0.82, 0.94, spotAngle); + } + vec3 halfVector = normalize(lightDirection + viewDirection); + + float diffuse = max(dot(normal, lightDirection), 0.0); + float direct = diffuse * lightIntensity * attenuation; + vec3 reflected = reflect(-viewDirection, normal); + vec3 environment = textureCube(studioEnvironment, reflected).rgb; + + float substrateRoughness = roughness; + if (substrateMode == 0) substrateRoughness = max(roughness, 0.56); + if (substrateMode == 1) substrateRoughness = max(roughness, 0.70); + if (substrateMode == 2) substrateRoughness = min(roughness, 0.25); + if (substrateMode == 3) substrateRoughness = min(roughness, 0.20); + if (substrateMode == 4) substrateRoughness = max(roughness, 0.48); + + float nDotV = max(dot(normal, viewDirection), 0.0); + float dielectricFresnel = 0.04 + 0.96 * pow(1.0 - nDotV, 5.0); + float substrateSpecular = pow( + max(dot(normal, halfVector), 0.0), + mix(180.0, 8.0, substrateRoughness) + ) * diffuse * lightIntensity * attenuation; + vec3 printedInk = artworkSample.rgb * surfaceShade * (0.32 + direct * lightColor); + // Substrate reflection is evaluated before the plain-ink return, so it + // remains visible with no foil or holo coating. + if (substrateMode == 0 || substrateMode == 1 || substrateMode == 4) { + float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045); + printedInk += lightColor * substrateSpecular * reflectionStrength; + } + if (substrateMode == 2) { + float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity); + printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction; + printedInk += lightColor * min(0.50, substrateSpecular * 0.38); + } + if (substrateMode == 3) { + float visibleEtch = metalEtchDepth * metalEtchExposure; + float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch)); + vec3 metalUnderprint = mix( + artworkSample.rgb * vec3(0.72, 0.76, 0.82), + environment, + 0.36 + 0.24 * pow(1.0 - max(dot(normal, viewDirection), 0.0), 2.0) + ); + metalUnderprint *= 1.0 - visibleEtch * 0.12; + metalUnderprint += lightColor * etchEdge * (0.035 + substrateSpecular * 0.04); + printedInk = mix(printedInk, metalUnderprint, 0.42); + printedInk *= 1.0 - visibleEtch * 0.06; + printedInk += lightColor * etchEdge * 0.025; + } + if (substrateMode == 4) { + float grain = woodGrain(artworkUv); + vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), grain); + printedInk *= mix(vec3(1.0), woodTint, 0.25); + } + if (finishMode == 0) { + vec3 wornInk = applyWearColor(printedInk, edgeWear, scratchWear, scuffWear); + gl_FragColor = vec4(wornInk, artworkSample.a); + #include + #include + return; + } + + float fresnel = pow(1.0 - max(dot(normal, viewDirection), 0.0), 3.0); + // A point-light Phong lobe must be broader than the equivalent microfacet + // roughness in Blender, otherwise foil only appears at a razor-thin angle. + float specularPower = mix(96.0, 10.0, substrateRoughness); + float directSpecular = pow(max(dot(normal, halfVector), 0.0), specularPower); + float environmentBrightness = dot(environment, vec3(0.2126, 0.7152, 0.0722)); + if (substrateMode == 3 && finishMode == 1) { + float plateSpecular = pow(max(dot(normal, halfVector), 0.0), 150.0); + float plateEdge = smoothstep(0.003, 0.055, fwidth(mask)); + vec3 champagne = vec3(1.0, 0.84, 0.58); + vec3 plateReflection = clamp( + environment * champagne * (0.55 + fresnel * 0.22) + + lightColor * champagne * plateSpecular * lightIntensity * attenuation * 0.22, + 0.0, + 1.25 + ); + vec3 screenedArtwork = 1.0 - (1.0 - artworkSample.rgb) * (1.0 - plateReflection * 0.42); + vec3 platedColor = mix(artworkSample.rgb, screenedArtwork, 0.38); + platedColor += lightColor * champagne * plateEdge * 0.008; + float plateCoverage = smoothstep(0.18, 0.88, mask); + float plateWeight = clamp( + plateCoverage * (0.18 + finishStrength * 0.22), + 0.0, + 0.34 + ); + vec3 plated = mix(printedInk, platedColor, plateWeight); + plated = applyWearColor(plated, edgeWear, scratchWear, scuffWear); + gl_FragColor = vec4(plated, artworkSample.a); + #include + #include + return; + } + + float coatingResponse = clamp( + 0.10 + + directSpecular * lightIntensity * attenuation * 0.46 + + (environmentBrightness * 0.34 + fresnel * 0.22) * environmentIntensity, + 0.0, + 0.78 + ); + if (finishMode == 2) { + coatingResponse = clamp( + 0.16 + + directSpecular * lightIntensity * attenuation * 1.25 + + (environmentBrightness * 0.72 + fresnel * 0.48) * environmentIntensity, + 0.0, + 1.8 + ); + } + + float phase = fract( + artworkUv.x * 0.65 + + artworkUv.y * 0.25 + + dot(viewDirection, vWorldTangent) * 2.8 + + dot(viewDirection, vWorldBitangent) * 1.6 + + dot(lightDirection, vWorldTangent) * 0.22 + + 0.30 + ); + + vec3 coatingColor; + if (finishMode == 2) { + vec3 spectralReflection = mix(environment, spectrum(phase), 0.72); + coatingColor = mix(artworkSample.rgb, spectralReflection, 0.48); + } else { + vec3 warmReflection = clamp( + environment * vec3(1.14, 1.02, 0.82) + + lightColor * directSpecular * 0.55, + 0.0, + 1.0 + ); + vec3 screenedInk = 1.0 - (1.0 - artworkSample.rgb) * (1.0 - warmReflection); + coatingColor = mix(artworkSample.rgb, screenedInk, 0.58); + } + + float coatingStrength = finishMode == 2 ? finishStrength : finishStrength * 0.8; + float coatingLimit = finishMode == 2 ? 0.92 : 0.48; + float coatingWeight = clamp(mask * coatingStrength * coatingResponse, 0.0, coatingLimit); + vec3 coated = mix(printedInk, coatingColor, coatingWeight); + coated = applyWearColor(coated, edgeWear, scratchWear, scuffWear); + gl_FragColor = vec4(coated, artworkSample.a); + #include + #include + } +` + +export function createCardMaterial( + artwork: THREE.Texture, + finishMask: THREE.Texture, + normalMap: THREE.Texture, + environment: THREE.CubeTexture, + lightPosition: THREE.Vector3, +) { + return new THREE.ShaderMaterial({ + uniforms: { + artwork: { value: artwork }, + finishMask: { value: finishMask }, + normalMap: { value: normalMap }, + studioEnvironment: { value: environment }, + lightPosition: { value: lightPosition }, + lightColor: { value: new THREE.Color('#fff0d0') }, + lightIntensity: { value: 3.0 }, + environmentIntensity: { value: 0.7 }, + finishStrength: { value: 0.6 }, + roughness: { value: 0.23 }, + normalStrength: { value: 0.14 }, + condition: { value: 1.0 }, + imperfectionSeed: { value: 81251 }, + finishMode: { value: 2 }, + substrateMode: { value: 0 }, + lightMode: { value: 0 }, + }, + vertexShader, + fragmentShader, + side: THREE.DoubleSide, + toneMapped: true, + }) +} + +export function applyMaterialControls( + material: THREE.ShaderMaterial, + controls: MaterialControls, +) { + const finishModes: Record = { + 'Printed ink': 0, + Foil: 1, + Holographic: 2, + } + const substrateModes: Record = { + Paper: 0, + Linen: 1, + Plastic: 2, + Metal: 3, + Wood: 4, + } + material.uniforms.finishMode.value = finishModes[controls.finish] + material.uniforms.substrateMode.value = substrateModes[controls.substrate] + material.uniforms.finishStrength.value = controls.finishStrength + material.uniforms.roughness.value = controls.roughness + material.uniforms.normalStrength.value = controls.normalStrength + material.uniforms.environmentIntensity.value = controls.environmentIntensity + material.uniforms.condition.value = controls.condition + material.uniforms.imperfectionSeed.value = controls.imperfectionSeed +} diff --git a/sanctification-tcg/card-harness/src/foilWrapper.ts b/sanctification-tcg/card-harness/src/foilWrapper.ts new file mode 100644 index 0000000..726062f --- /dev/null +++ b/sanctification-tcg/card-harness/src/foilWrapper.ts @@ -0,0 +1,146 @@ +import * as THREE from 'three' + +const halfWidth = 1.68 +const bottom = -2.24 +const seam = 1.97 +const top = 2.28 +const midDepth = -0.09 +const smooth = THREE.MathUtils.smoothstep + +function wrapperTexture(back = false) { + const canvas = document.createElement('canvas') + canvas.width = 1024 + canvas.height = 1400 + const context = canvas.getContext('2d')! + context.fillStyle = '#263a40' + context.fillRect(0, 0, 1024, 1400) + const gradient = context.createLinearGradient(0, 0, 1024, 1400) + gradient.addColorStop(0, '#ffffff0c') + gradient.addColorStop(0.45, '#ffffff00') + gradient.addColorStop(1, '#00000022') + context.fillStyle = gradient + context.fillRect(0, 0, 1024, 1400) + context.strokeStyle = '#a59569' + context.lineWidth = 2 + context.strokeRect(66, 155, 892, 1100) + context.strokeRect(80, 169, 864, 1072) + context.fillStyle = '#baa775' + context.fillRect(0, 0, 1024, 100) + context.fillRect(0, 1315, 1024, 85) + context.textAlign = 'center' + context.fillStyle = '#283339' + context.font = 'bold 19px sans-serif' + context.fillText('P U L L T O O P E N →', 512, 58) + context.fillStyle = '#d8c994' + context.save() + context.translate(512, 480) + context.rotate(Math.PI / 4) + context.strokeRect(-100, -100, 200, 200) + context.strokeRect(-80, -80, 160, 160) + context.restore() + context.font = '56px Georgia' + context.fillText('S', 512, 500) + context.font = '44px Georgia' + context.fillText('SANCTIFICATION', 512, 740) + context.font = '21px sans-serif' + context.fillText('C O L L E C T O R S E R I E S', 512, 795) + context.font = '19px sans-serif' + context.fillText(back ? 'AUTHORED EDITION / 001' : 'THREE CARDS / ONE COLLECTION', 512, 1175) + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + return texture +} + +/** + * Two joined pouch surfaces and one sealed ribbon, never a label over a shell. + * All deformation is evaluated from immutable UVs, not accumulated frame deltas. + */ +export function createFoilWrapper() { + const root = new THREE.Group() + root.name = 'PACK_FOIL_WRAPPER' + const frontTexture = wrapperTexture() + const frontMaterial = new THREE.MeshStandardMaterial({ + map: frontTexture, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide, + }) + const backMaterial = frontMaterial.clone() + backMaterial.map = wrapperTexture(true) + const stripMaterial = frontMaterial.clone() + const sheets = [1, -1].map((side) => { + const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80) + const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial) + mesh.name = side === 1 ? 'FOIL_FRONT' : 'FOIL_BACK' + // UVs span the original, uncut printed sheet; geometry UVs never change. + const uv = geometry.getAttribute('uv') + for (let i = 0; i < uv.count; i++) uv.setY(i, uv.getY(i) * (seam - bottom) / (top - bottom)) + root.add(mesh) + return { mesh, side } + }) + // Match the pouch's horizontal tessellation so the attached tear boundary shares every sample. + const stripGeometry = new THREE.PlaneGeometry(halfWidth * 2, top - seam, 64, 10) + const stripUV = stripGeometry.getAttribute('uv') + for (let i = 0; i < stripUV.count; i++) { + stripUV.setY(i, (seam - bottom + stripUV.getY(i) * (top - seam)) / (top - bottom)) + } + const strip = new THREE.Mesh(stripGeometry, stripMaterial) + strip.name = 'FOIL_TEAR_STRIP' + root.add(strip) + + function tornEdge(u: number) { + return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173) + } + + function deform(tear: number, detach: number, mouth: number, touch: THREE.Vector3, stripExit = 4.8) { + for (const { mesh, side } of sheets) { + const geometry = mesh.geometry + const positions = geometry.getAttribute('position') + const uv = geometry.getAttribute('uv') + for (let i = 0; i < positions.count; i++) { + const u = uv.getX(i) + const y = bottom + uv.getY(i) * (top - bottom) + const x = (u * 2 - 1) * halfWidth + const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24) + const lower = smooth(y, bottom + 0.14, bottom + 0.47) + const neck = 1 - smooth(y, 1.65, seam) + const opening = mouth * smooth(y, 0.6, seam) + const envelope = across * lower + const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2) + const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5)) + * envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5)) + const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36)) + * (1 - lower) * smooth(y, bottom, bottom + 0.1) * across + const dent = side === 1 ? -0.045 * touch.z + * Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0 + const depth = 0.37 * neck + 0.64 * opening + const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * smooth(y, 1, seam) + positions.setXYZ(i, x, y + tornEdge(u) * smooth(y, 1.85, seam) + lip, + midDepth + side * (depth * envelope + folds * neck + crimp) + dent) + } + positions.needsUpdate = true + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + } + const positions = stripGeometry.getAttribute('position') + for (let i = 0; i < positions.count; i++) { + const u = stripUV.getX(i) + const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam) + const length = Math.max(0, tear - u) * halfWidth * 2 + const curvature = 0.55 + detach * 0.5 + const angle = length * curvature + const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth + const release = smooth(length, 0, 0.3) + // The released end rolls up around the moving tear front; the attached end stays sealed. + positions.setXYZ(i, + anchor - Math.sin(angle) / curvature + detach * stripExit, + seam + v * (top - seam) + tornEdge(u) * (1 - v) + + 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4, + midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI) + - 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65) + } + positions.needsUpdate = true + stripGeometry.computeVertexNormals() + stripGeometry.computeBoundingSphere() + strip.visible = detach < 1 + } + deform(0, 0, 0, new THREE.Vector3()) + return { root, deform } +} diff --git a/sanctification-tcg/card-harness/src/main.ts b/sanctification-tcg/card-harness/src/main.ts new file mode 100644 index 0000000..6c91247 --- /dev/null +++ b/sanctification-tcg/card-harness/src/main.ts @@ -0,0 +1,1476 @@ +import './style.css' +import * as THREE from 'three' +import GUI from 'lil-gui' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import { + applyEdgeMaterialControls, + applyMaterialControls, + createCardMaterial, + createStudioCubeTexture, + type FinishName, + type MaterialControls, + type SubstrateName, +} from './cardMaterial' +import { createCardGeometry } from './cardGeometry' +import { PackOpening, type PackFixture } from './packOpening' + +type FixtureName = PackFixture +type AppMode = 'Inspect' | 'Lab' | 'Pack' +type ManipulationTarget = 'Card' | 'Camera' +type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back' +type LightType = 'Point' | 'Directional' | 'Spot' +type LightingPreset = 'Studio' | 'Warm gallery' | 'Cool window' | 'Dramatic spot' | 'Flat review' | 'Custom' +type EnvironmentName = 'Studio' | 'Warm' | 'Cool' | 'Dark' | 'Neutral' + +interface ExperimentSnapshot { + schemaVersion: 1 | 2 + runtimeRevision: 'runtime-look-v4-2026-09-07' + name: string + savedAt: string + fixture: FixtureName + customArtworkName?: string + customMaskName?: string + finish: FinishName + substrate: SubstrateName + finishStrength: number + roughness: number + normalStrength: number + environmentIntensity: number + condition?: number + imperfectionSeed?: number + lightingPreset: LightingPreset + environment: EnvironmentName + lightType: LightType + lightColor: string + lightPosition: [number, number, number] + lightIntensity: number + exposure: number + cardRotation: [number, number, number] + cameraPosition: [number, number, number] + orbitTarget: [number, number, number] +} + +const fixtureAssets: Record = { + David: { + artwork: '/reference/david-front.png', + mask: '/reference/david-finish-mask.png', + }, + Timothy: { + artwork: '/reference/timothy-front.png', + mask: '/reference/timothy-finish-mask.png', + }, +} + +function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) { + texture.colorSpace = colorSpace + texture.anisotropy = renderer.capabilities.getMaxAnisotropy() +} + +document.querySelector('#app')!.innerHTML = ` +
+
+
+
+

Renderer proof · runtime look v4

+

Sanctification Card Lab

+
+ +
+
+ + + + + + + + + + +
+
+ +
+ + +
+ Preparing reference assets… + Frame time — +
+
Drag the card · pinch or wheel to zoom · flip to inspect the back
+ +
+ + + + + +
+` + +const canvas = document.querySelector('#scene')! +const statusElement = document.querySelector('#status')! +const performanceElement = document.querySelector('#performance')! +const loadingElement = document.querySelector('#loading')! +const hintElement = document.querySelector('#hint')! +const topbar = document.querySelector('.topbar')! +const controlsToggle = document.querySelector('#controls-toggle')! +const modeSelect = document.querySelector('#mode')! +const fixtureSelect = document.querySelector('#fixture')! +const finishSelect = document.querySelector('#finish')! +const substrateSelect = document.querySelector('#substrate')! +const poseSelect = document.querySelector('#pose')! +const lightingPresetSelect = document.querySelector('#lighting-preset')! +const lightTypeSelect = document.querySelector('#light-type')! +const flipButton = document.querySelector('#flip')! +const resetButton = document.querySelector('#reset')! +const sweepButton = document.querySelector('#sweep')! +const artworkFileInput = document.querySelector('#artwork-file')! +const maskFileInput = document.querySelector('#mask-file')! +const experimentFileInput = document.querySelector('#experiment-file')! +const packControlsElement = document.querySelector('#pack-controls')! +const packStatusElement = document.querySelector('#pack-status')! +const packTearProgress = document.querySelector('#pack-tear')! +const packPrimaryButton = document.querySelector('#pack-primary')! +const packRestartButton = document.querySelector('#pack-restart')! +const packSkipButton = document.querySelector('#pack-skip')! + +function setControlsCollapsed(collapsed: boolean) { + topbar.classList.toggle('controls-collapsed', collapsed) + document.documentElement.classList.toggle('compact-controls', collapsed) + controlsToggle.setAttribute('aria-expanded', String(!collapsed)) + controlsToggle.querySelector('.controls-toggle-icon')!.textContent = + collapsed ? 'v' : '^' +} + +setControlsCollapsed(window.matchMedia('(max-width: 880px)').matches) + +const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: false, + powerPreference: 'high-performance', +}) +renderer.outputColorSpace = THREE.SRGBColorSpace +renderer.toneMapping = THREE.ACESFilmicToneMapping +renderer.toneMappingExposure = 1.0 +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + +const scene = new THREE.Scene() +scene.background = new THREE.Color('#0b0e14') + +const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100) +camera.position.set(0, 0, 8.2) + +const orbit = new OrbitControls(camera, canvas) +orbit.enableDamping = true +orbit.dampingFactor = 0.08 +orbit.enablePan = true +orbit.minDistance = 5.0 +orbit.maxDistance = 12 +orbit.target.set(0, 0, 0) +orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE +orbit.mouseButtons.RIGHT = THREE.MOUSE.PAN +orbit.enabled = false + +const environments: Record = { + Studio: createStudioCubeTexture(), + Warm: createStudioCubeTexture(['#e1aa62', '#402419', '#fff0c8', '#20120c', '#9c5d35', '#28130d']), + Cool: createStudioCubeTexture(['#94bfe8', '#14283d', '#e8f4ff', '#0b1522', '#527da8', '#111c2d']), + Dark: createStudioCubeTexture(['#544836', '#090d16', '#8b7655', '#05070c', '#27344c', '#120d0b']), + Neutral: createStudioCubeTexture(['#d9dde2', '#30343a', '#ffffff', '#1a1d22', '#9ca3ac', '#25282e']), +} +let activeEnvironmentName: EnvironmentName = 'Studio' +let activeEnvironment = environments[activeEnvironmentName] +scene.environment = activeEnvironment + +const cardRoot = new THREE.Group() +scene.add(cardRoot) + +const edgeMaterial = new THREE.MeshPhysicalMaterial({ + color: '#9c8c69', + roughness: 0.48, + metalness: 0.05, + clearcoat: 0.18, +}) +const loader = new THREE.TextureLoader() +const normalMap = await loader.loadAsync('/reference/linen-normal.png') +normalMap.colorSpace = THREE.NoColorSpace +normalMap.wrapS = normalMap.wrapT = THREE.RepeatWrapping + +const backTexture = await loader.loadAsync('/reference/card-back.png') +backTexture.colorSpace = THREE.SRGBColorSpace +backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy() + +const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork) +const initialMask = await loader.loadAsync(fixtureAssets.David.mask) +configureFrontTexture(initialArtwork, THREE.SRGBColorSpace) +configureFrontTexture(initialMask, THREE.NoColorSpace) + +const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4) +const frontMaterial = createCardMaterial( + initialArtwork, + initialMask, + normalMap, + activeEnvironment, + lightPosition, +) +const backMaterial = new THREE.MeshPhysicalMaterial({ + map: backTexture, + roughness: 0.42, + metalness: 0, + clearcoat: 0.2, + clearcoatRoughness: 0.24, + normalMap: null, + side: THREE.DoubleSide, +}) + +cardRoot.add(createCardGeometry(frontMaterial, backMaterial, edgeMaterial)) + +const lightTarget = new THREE.Object3D() +scene.add(lightTarget) + +const pointLight = new THREE.PointLight('#fff0d0', 34, 20, 1.7) +const directionalLight = new THREE.DirectionalLight('#fff0d0', 5.4) +const spotLight = new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7) +directionalLight.target = lightTarget +spotLight.target = lightTarget +for (const light of [pointLight, directionalLight, spotLight]) { + light.position.copy(lightPosition) + light.castShadow = false + scene.add(light) +} + +const floor = new THREE.Mesh( + new THREE.PlaneGeometry(30, 30), + new THREE.MeshStandardMaterial({ color: '#090b10', roughness: 0.82, metalness: 0.05 }), +) +floor.rotation.x = -Math.PI / 2 +floor.position.y = -2.7 +floor.receiveShadow = true +scene.add(floor) + +const controls: MaterialControls & { + lightX: number + lightY: number + lightZ: number + lightIntensity: number + lightColor: string + lightType: LightType + lightingPreset: LightingPreset + exposure: number + target: ManipulationTarget +} = { + finish: 'Holographic', + substrate: 'Paper', + finishStrength: 0.6, + roughness: 0.23, + normalStrength: 0.14, + environmentIntensity: 0.7, + condition: 1, + imperfectionSeed: 81251, + lightX: lightPosition.x, + lightY: lightPosition.y, + lightZ: lightPosition.z, + lightIntensity: 3, + lightColor: '#fff0d0', + lightType: 'Point', + lightingPreset: 'Studio', + exposure: 1, + target: 'Card', +} + +const lightingPresets: Record, { + environment: EnvironmentName + lightType: LightType + lightColor: string + lightPosition: [number, number, number] + lightIntensity: number + environmentIntensity: number + exposure: number +}> = { + Studio: { + environment: 'Studio', + lightType: 'Point', + lightColor: '#fff0d0', + lightPosition: [2.2, 2.5, 4.4], + lightIntensity: 3, + environmentIntensity: 0.7, + exposure: 1, + }, + 'Warm gallery': { + environment: 'Warm', + lightType: 'Spot', + lightColor: '#ffd29a', + lightPosition: [-2.8, 3.1, 4.8], + lightIntensity: 4, + environmentIntensity: 0.52, + exposure: 1.04, + }, + 'Cool window': { + environment: 'Cool', + lightType: 'Directional', + lightColor: '#bddcff', + lightPosition: [-3.4, 4.2, 5.2], + lightIntensity: 2.7, + environmentIntensity: 0.78, + exposure: 1, + }, + 'Dramatic spot': { + environment: 'Dark', + lightType: 'Spot', + lightColor: '#ffe1ad', + lightPosition: [3.6, 1.9, 3.6], + lightIntensity: 4.8, + environmentIntensity: 0.28, + exposure: 0.92, + }, + 'Flat review': { + environment: 'Neutral', + lightType: 'Directional', + lightColor: '#ffffff', + lightPosition: [0.6, 0.8, 5.5], + lightIntensity: 2.25, + environmentIntensity: 1, + exposure: 1, + }, +} +let applyingLightingPreset = false + +const gui = new GUI({ title: 'Material proof' }) +const surfaceFolder = gui.addFolder('Surface') +surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).name('Finish').onChange((value: FinishName) => { + pauseScriptedMotion() + finishSelect.value = value + updateMaterial() + updateStatus() +}) +surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => { + pauseScriptedMotion() + substrateSelect.value = value + updateMaterial() + updateEdgeMaterial() + updateStatus() +}) +surfaceFolder.add(controls, 'finishStrength', 0, 1, 0.01).name('Finish strength').onChange(() => { + pauseScriptedMotion() + updateMaterial() +}) +surfaceFolder.add(controls, 'roughness', 0.05, 0.8, 0.01).name('Roughness').onChange(() => { + pauseScriptedMotion() + updateMaterial() +}) +surfaceFolder.add(controls, 'normalStrength', 0, 0.45, 0.01).name('Surface detail').onChange(() => { + pauseScriptedMotion() + updateMaterial() +}) +surfaceFolder.add(controls, 'environmentIntensity', 0, 1.5, 0.01).name('Environment').onChange(() => { + pauseScriptedMotion() + markLightingCustom() + updateMaterial() +}) + +const wearFolder = gui.addFolder('Wear') +wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => { + pauseScriptedMotion() + updateMaterial() + updateEdgeMaterial() +}) +wearFolder.add(controls, 'imperfectionSeed', 1, 99999, 1).name('Seed').onChange(() => { + pauseScriptedMotion() + updateMaterial() +}) +wearFolder.add({ + randomize: () => { + pauseScriptedMotion() + const randomValue = crypto.getRandomValues(new Uint32Array(1))[0] + controls.imperfectionSeed = 1 + randomValue % 99999 + updateMaterial() + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + }, +}, 'randomize').name('Randomize seed') + +const lightFolder = gui.addFolder('Light') +lightFolder.add(controls, 'lightingPreset', ['Studio', 'Warm gallery', 'Cool window', 'Dramatic spot', 'Flat review', 'Custom']).name('Preset').onChange((value: LightingPreset) => { + if (value !== 'Custom') applyLightingPreset(value) +}) +lightFolder.add(controls, 'lightType', ['Point', 'Directional', 'Spot']).name('Type').onChange((value: LightType) => { + pauseScriptedMotion() + controls.lightType = value + lightTypeSelect.value = value + markLightingCustom() + updateLight() +}) +lightFolder.addColor(controls, 'lightColor').name('Color').onChange(() => { + pauseScriptedMotion() + markLightingCustom() + updateLight() +}) +lightFolder.add(controls, 'lightX', -6, 6, 0.05).name('X').onChange(() => { + const value = controls.lightX + pauseScriptedMotion() + controls.lightX = value + markLightingCustom() + updateLight() +}) +lightFolder.add(controls, 'lightY', -6, 6, 0.05).name('Y').onChange(() => { + const value = controls.lightY + pauseScriptedMotion() + controls.lightY = value + markLightingCustom() + updateLight() +}) +lightFolder.add(controls, 'lightZ', 0.5, 9, 0.05).name('Z').onChange(() => { + const value = controls.lightZ + pauseScriptedMotion() + controls.lightZ = value + markLightingCustom() + updateLight() +}) +lightFolder.add(controls, 'lightIntensity', 0, 8, 0.05).name('Intensity').onChange(() => { + pauseScriptedMotion() + markLightingCustom() + updateLight() +}) +lightFolder.add(controls, 'exposure', 0.5, 1.8, 0.01).name('Exposure').onChange(() => { + pauseScriptedMotion() + markLightingCustom() + renderer.toneMappingExposure = controls.exposure +}) +gui.add(controls, 'target', ['Card', 'Camera']).name('Drag target').onChange(() => { + pauseScriptedMotion() + updateMode() +}) + +const assetActions = { + loadArtwork: () => artworkFileInput.click(), + loadMask: () => maskFileInput.click(), + restoreFixture: () => { + pauseScriptedMotion() + void loadFixture(fixtureSelect.value as FixtureName) + }, +} +const assetFolder = gui.addFolder('Assets') +assetFolder.add(assetActions, 'loadArtwork').name('Load artwork image') +assetFolder.add(assetActions, 'loadMask').name('Load finish mask') +assetFolder.add(assetActions, 'restoreFixture').name('Restore selected card') + +const experimentControls = { + name: 'comparison-1', +} +const experimentActions = { + save: () => saveExperimentToBrowser(), + load: () => void loadExperimentFromBrowser(), + remove: () => removeExperimentFromBrowser(), + exportJson: () => exportExperimentJson(), + importJson: () => experimentFileInput.click(), + capturePng: () => captureExperimentPng(), +} +const experimentFolder = gui.addFolder('Experiments') +experimentFolder.add(experimentControls, 'name').name('Name') +experimentFolder.add(experimentActions, 'save').name('Save in browser') +experimentFolder.add(experimentActions, 'load').name('Load from browser') +experimentFolder.add(experimentActions, 'remove').name('Delete browser save') +experimentFolder.add(experimentActions, 'exportJson').name('Export JSON') +experimentFolder.add(experimentActions, 'importJson').name('Import JSON') +experimentFolder.add(experimentActions, 'capturePng').name('Capture PNG') +gui.hide() + +let mode: AppMode = 'Inspect' +let textureRequest = 0 +let activeFixture: FixtureName = 'David' +let customArtworkName: string | undefined +let customMaskName: string | undefined +let dragging = false +let lastPointer = new THREE.Vector2() +const activePointers = new Map() +let lastPinchDistance: number | undefined +const rotationTarget = new THREE.Euler() +let scriptedMotion = false +let scriptStartedAt = 0 +let scriptStartRotation = new THREE.Euler() +let scriptStartLight = new THREE.Vector3() +let scriptTargetLightX = 0 +const clock = new THREE.Clock() +const frameTimes: number[] = [] +let lastPerformanceUpdate = performance.now() +let lastFrameTime: number | undefined +let pack: PackOpening | undefined +let packLoading = false +let packError: string | undefined + +function updateMaterial() { + applyMaterialControls(frontMaterial, controls) + pack?.syncLighting(frontMaterial) +} + +function updateEdgeMaterial() { + applyEdgeMaterialControls(edgeMaterial, controls) +} + +function syncRuntimeLightPositions() { + pointLight.position.copy(lightPosition) + directionalLight.position.copy(lightPosition) + spotLight.position.copy(lightPosition) +} + +function updateLight() { + lightPosition.set(controls.lightX, controls.lightY, controls.lightZ) + syncRuntimeLightPositions() + pointLight.color.set(controls.lightColor) + directionalLight.color.set(controls.lightColor) + spotLight.color.set(controls.lightColor) + pointLight.intensity = controls.lightIntensity * 11.3 + directionalLight.intensity = controls.lightIntensity * 1.8 + spotLight.intensity = controls.lightIntensity * 18 + pointLight.visible = controls.lightType === 'Point' + directionalLight.visible = controls.lightType === 'Directional' + spotLight.visible = controls.lightType === 'Spot' + const lightModes: Record = { + Point: 0, + Directional: 1, + Spot: 2, + } + frontMaterial.uniforms.lightMode.value = lightModes[controls.lightType] + frontMaterial.uniforms.lightColor.value.set(controls.lightColor) + frontMaterial.uniforms.lightIntensity.value = controls.lightIntensity + pack?.syncLighting(frontMaterial) +} + +function markLightingCustom() { + if (applyingLightingPreset || controls.lightingPreset === 'Custom') return + controls.lightingPreset = 'Custom' + lightingPresetSelect.value = 'Custom' + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) +} + +function applyLightingPreset(presetName: Exclude) { + pauseScriptedMotion() + applyingLightingPreset = true + const preset = lightingPresets[presetName] + controls.lightingPreset = presetName + controls.lightType = preset.lightType + controls.lightColor = preset.lightColor + controls.lightX = preset.lightPosition[0] + controls.lightY = preset.lightPosition[1] + controls.lightZ = preset.lightPosition[2] + controls.lightIntensity = preset.lightIntensity + controls.environmentIntensity = preset.environmentIntensity + controls.exposure = preset.exposure + lightingPresetSelect.value = presetName + lightTypeSelect.value = preset.lightType + activeEnvironmentName = preset.environment + activeEnvironment = environments[activeEnvironmentName] + scene.environment = activeEnvironment + frontMaterial.uniforms.studioEnvironment.value = activeEnvironment + renderer.toneMappingExposure = preset.exposure + updateMaterial() + updateLight() + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + applyingLightingPreset = false +} + +function updateMode() { + const previousMode = mode + mode = modeSelect.value as AppMode + if (previousMode !== mode) { + dragging = false + lastPinchDistance = undefined + for (const id of activePointers.keys()) { + if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id) + } + activePointers.clear() + if (mode === 'Pack') { + // Freeze the normal pose, including a partially finished flip, while it is hidden. + rotationTarget.copy(cardRoot.rotation) + loadingElement.hidden = true + if (window.matchMedia('(max-width: 880px)').matches) setControlsCollapsed(true) + void preparePack() + } else { + pack?.setActive(false) + } + } + cardRoot.visible = mode !== 'Pack' + document.documentElement.classList.toggle('pack-mode', mode === 'Pack') + packControlsElement.hidden = mode !== 'Pack' + const cameraTarget = mode === 'Lab' && controls.target === 'Camera' + orbit.enabled = cameraTarget + canvas.classList.toggle('camera-target', cameraTarget) + gui.show(mode === 'Lab') + hintElement.textContent = mode === 'Inspect' + ? 'Drag the card · pinch or wheel to zoom · flip to inspect the back' + : cameraTarget + ? 'Camera target · drag to orbit · pinch or wheel to zoom' + : 'Card target · drag to rotate · pinch or wheel to zoom' + updateStatus() +} + +function updateStatus() { + if (mode === 'Pack') { + updatePackUI() + return + } + const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : activeFixture + const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : '' + statusElement.textContent = `${artworkLabel} · ${controls.substrate} · ${controls.finish}${maskLabel}` +} + +function updatePackUI() { + if (mode !== 'Pack') return + const view = pack?.view + packControlsElement.dataset.state = pack?.state ?? (packError ? 'error' : 'loading') + packControlsElement.dataset.paused = String(pack?.paused ?? false) + packTearProgress.hidden = !!pack && pack.state !== 'sealed' && pack.state !== 'opening' + packTearProgress.value = pack?.tearProgress ?? 0 + packPrimaryButton.textContent = view?.action ?? (packError ? 'Retry loading' : 'Loading…') + packPrimaryButton.disabled = packLoading || pack?.state === 'complete' + packRestartButton.disabled = !pack + packSkipButton.disabled = !pack || pack.state === 'complete' + packSkipButton.title = 'Finish this motion or move to the next resting state' + const status = view?.status ?? packError ?? 'Preparing the prototype pack…' + if (packStatusElement.textContent !== status) packStatusElement.textContent = status + statusElement.textContent = 'Foil tear proof · 3 authored cards · no random rewards' + hintElement.textContent = view?.hint ?? 'Local reference artwork · approved runtime card materials' +} + +async function preparePack() { + if (pack) { + pack.syncLighting(frontMaterial) + pack.setActive(true) + updatePackUI() + return + } + if (packLoading) return + packLoading = true + packError = undefined + updatePackUI() + try { + // Separate texture ownership keeps local artwork, experiments and fixture disposal independent. + const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask, + fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask] + const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path))) + const failure = results.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') { + for (const result of results) { + if (result.status === 'fulfilled') result.value.dispose() + } + throw failure.reason + } + const textures = results.map((result, index) => { + if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`) + configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace) + return result.value + }) + pack = new PackOpening({ + canvas, + textures: { + David: { artwork: textures[0], mask: textures[1] }, + Timothy: { artwork: textures[2], mask: textures[3] }, + }, + backMaterial, + normalMap, + environment: activeEnvironment, + lightPosition, + onChange: updatePackUI, + }) + scene.add(pack.root) + pack.syncLighting(frontMaterial) + pack.setActive(mode === 'Pack') + pack.resize(canvas.clientWidth, canvas.clientHeight) + } catch (error) { + packError = `Pack assets unavailable: ${error instanceof Error ? error.message : String(error)}` + console.error('Could not prepare pack prototype', error) + } finally { + packLoading = false + updatePackUI() + } +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function isVectorTuple(value: unknown): value is [number, number, number] { + return Array.isArray(value) && + value.length === 3 && + value.every(isFiniteNumber) +} + +function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot { + if (typeof value !== 'object' || value === null) return false + const snapshot = value as Record + const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy' + const finishValid = + snapshot.finish === 'Printed ink' || + snapshot.finish === 'Foil' || + snapshot.finish === 'Holographic' + const substrateValid = + snapshot.substrate === 'Paper' || + snapshot.substrate === 'Linen' || + snapshot.substrate === 'Plastic' || + snapshot.substrate === 'Metal' || + snapshot.substrate === 'Wood' + const lightingPresetValid = + snapshot.lightingPreset === 'Studio' || + snapshot.lightingPreset === 'Warm gallery' || + snapshot.lightingPreset === 'Cool window' || + snapshot.lightingPreset === 'Dramatic spot' || + snapshot.lightingPreset === 'Flat review' || + snapshot.lightingPreset === 'Custom' + const environmentValid = + snapshot.environment === 'Studio' || + snapshot.environment === 'Warm' || + snapshot.environment === 'Cool' || + snapshot.environment === 'Dark' || + snapshot.environment === 'Neutral' + const lightTypeValid = + snapshot.lightType === 'Point' || + snapshot.lightType === 'Directional' || + snapshot.lightType === 'Spot' + const optionalNamesValid = + (snapshot.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') && + (snapshot.customMaskName === undefined || typeof snapshot.customMaskName === 'string') + + const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2 + const wearValid = snapshot.schemaVersion === 1 || + (isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed)) + + return schemaValid && + snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' && + typeof snapshot.name === 'string' && + typeof snapshot.savedAt === 'string' && + fixtureValid && + optionalNamesValid && + finishValid && + substrateValid && + isFiniteNumber(snapshot.finishStrength) && + isFiniteNumber(snapshot.roughness) && + isFiniteNumber(snapshot.normalStrength) && + isFiniteNumber(snapshot.environmentIntensity) && + wearValid && + lightingPresetValid && + environmentValid && + lightTypeValid && + typeof snapshot.lightColor === 'string' && + /^#[0-9a-f]{6}$/i.test(snapshot.lightColor) && + isVectorTuple(snapshot.lightPosition) && + isFiniteNumber(snapshot.lightIntensity) && + isFiniteNumber(snapshot.exposure) && + isVectorTuple(snapshot.cardRotation) && + isVectorTuple(snapshot.cameraPosition) && + isVectorTuple(snapshot.orbitTarget) +} + +function normalizedExperimentName() { + const name = experimentControls.name.trim() + if (!name) { + statusElement.textContent = 'Enter an experiment name first.' + return undefined + } + return name +} + +function createExperimentSnapshot(name: string): ExperimentSnapshot { + pauseScriptedMotion() + return { + schemaVersion: 2, + runtimeRevision: 'runtime-look-v4-2026-09-07', + name, + savedAt: new Date().toISOString(), + fixture: activeFixture, + customArtworkName, + customMaskName, + finish: controls.finish, + substrate: controls.substrate, + finishStrength: controls.finishStrength, + roughness: controls.roughness, + normalStrength: controls.normalStrength, + environmentIntensity: controls.environmentIntensity, + condition: controls.condition, + imperfectionSeed: controls.imperfectionSeed, + lightingPreset: controls.lightingPreset, + environment: activeEnvironmentName, + lightType: controls.lightType, + lightColor: controls.lightColor, + lightPosition: [lightPosition.x, lightPosition.y, lightPosition.z], + lightIntensity: controls.lightIntensity, + exposure: controls.exposure, + cardRotation: [cardRoot.rotation.x, cardRoot.rotation.y, cardRoot.rotation.z], + cameraPosition: [camera.position.x, camera.position.y, camera.position.z], + orbitTarget: [orbit.target.x, orbit.target.y, orbit.target.z], + } +} + +function safeFilename(name: string) { + const safe = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + return safe || 'card-experiment' +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +function experimentStorageKey(name: string) { + return `sanctification-card-lab:experiment:${name}` +} + +function saveExperimentToBrowser() { + const name = normalizedExperimentName() + if (!name) return + try { + localStorage.setItem( + experimentStorageKey(name), + JSON.stringify(createExperimentSnapshot(name)), + ) + statusElement.textContent = `Saved experiment "${name}" in this browser.` + } catch (error) { + statusElement.textContent = + `Could not save experiment: ${error instanceof Error ? error.message : String(error)}` + } +} + +async function loadExperimentFromBrowser() { + const name = normalizedExperimentName() + if (!name) return + try { + const stored = localStorage.getItem(experimentStorageKey(name)) + if (stored === null) { + statusElement.textContent = `No browser experiment named "${name}".` + return + } + const parsed: unknown = JSON.parse(stored) + if (!isExperimentSnapshot(parsed)) { + statusElement.textContent = `Experiment "${name}" has an unsupported or invalid format.` + return + } + await applyExperimentSnapshot(parsed) + } catch (error) { + statusElement.textContent = + `Could not load experiment: ${error instanceof Error ? error.message : String(error)}` + } +} + +function removeExperimentFromBrowser() { + const name = normalizedExperimentName() + if (!name) return + const key = experimentStorageKey(name) + if (localStorage.getItem(key) === null) { + statusElement.textContent = `No browser experiment named "${name}".` + return + } + localStorage.removeItem(key) + statusElement.textContent = `Deleted browser experiment "${name}".` +} + +function exportExperimentJson() { + const name = normalizedExperimentName() + if (!name) return + const snapshot = createExperimentSnapshot(name) + downloadBlob( + new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json' }), + `${safeFilename(name)}.card-experiment.json`, + ) + statusElement.textContent = `Exported experiment "${name}".` +} + +function captureExperimentPng() { + const name = normalizedExperimentName() + if (!name) return + pauseScriptedMotion() + resizeRenderer() + renderer.render(scene, camera) + canvas.toBlob((blob) => { + if (blob === null) { + statusElement.textContent = 'Could not capture the current render.' + return + } + downloadBlob(blob, `${safeFilename(name)}.png`) + statusElement.textContent = `Captured experiment "${name}".` + }, 'image/png') +} + +async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) { + pauseScriptedMotion() + fixtureSelect.value = snapshot.fixture + await loadFixture(snapshot.fixture) + controls.finish = snapshot.finish + controls.substrate = snapshot.substrate + controls.finishStrength = THREE.MathUtils.clamp(snapshot.finishStrength, 0, 1) + controls.roughness = THREE.MathUtils.clamp(snapshot.roughness, 0.05, 0.8) + controls.normalStrength = THREE.MathUtils.clamp(snapshot.normalStrength, 0, 0.45) + controls.environmentIntensity = THREE.MathUtils.clamp(snapshot.environmentIntensity, 0, 1.5) + controls.condition = THREE.MathUtils.clamp(snapshot.condition ?? 1, 0, 1) + controls.imperfectionSeed = Math.round( + THREE.MathUtils.clamp(snapshot.imperfectionSeed ?? 81251, 1, 99999), + ) + controls.lightingPreset = snapshot.lightingPreset + controls.lightType = snapshot.lightType + controls.lightColor = snapshot.lightColor + controls.lightX = THREE.MathUtils.clamp(snapshot.lightPosition[0], -6, 6) + controls.lightY = THREE.MathUtils.clamp(snapshot.lightPosition[1], -6, 6) + controls.lightZ = THREE.MathUtils.clamp(snapshot.lightPosition[2], 0.5, 9) + controls.lightIntensity = THREE.MathUtils.clamp(snapshot.lightIntensity, 0, 8) + controls.exposure = THREE.MathUtils.clamp(snapshot.exposure, 0.5, 1.8) + activeEnvironmentName = snapshot.environment + activeEnvironment = environments[activeEnvironmentName] + scene.environment = activeEnvironment + frontMaterial.uniforms.studioEnvironment.value = activeEnvironment + renderer.toneMappingExposure = controls.exposure + finishSelect.value = controls.finish + substrateSelect.value = controls.substrate + lightingPresetSelect.value = controls.lightingPreset + lightTypeSelect.value = controls.lightType + poseSelect.value = 'Free' + cardRoot.rotation.set( + THREE.MathUtils.clamp(snapshot.cardRotation[0], -1.25, 1.25), + snapshot.cardRotation[1], + THREE.MathUtils.clamp(snapshot.cardRotation[2], -Math.PI, Math.PI), + ) + rotationTarget.copy(cardRoot.rotation) + camera.position.set(...snapshot.cameraPosition) + if (camera.position.lengthSq() < 0.0001) camera.position.set(0, 0, 8.2) + camera.position.setLength(THREE.MathUtils.clamp(camera.position.length(), 5, 12)) + orbit.target.set( + THREE.MathUtils.clamp(snapshot.orbitTarget[0], -5, 5), + THREE.MathUtils.clamp(snapshot.orbitTarget[1], -5, 5), + THREE.MathUtils.clamp(snapshot.orbitTarget[2], -5, 5), + ) + orbit.update() + updateMaterial() + updateEdgeMaterial() + updateLight() + experimentControls.name = snapshot.name + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + + const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName + ? ' Local artwork or masks must be loaded again.' + : '' + if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${customAssetNote}` +} + +function syncLightControls() { + controls.lightX = lightPosition.x + controls.lightY = lightPosition.y + controls.lightZ = lightPosition.z +} + +function pauseScriptedMotion() { + if (!scriptedMotion) return + scriptedMotion = false + rotationTarget.copy(cardRoot.rotation) + syncLightControls() + sweepButton.textContent = 'Play sweep' + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) +} + +function applyInspectionPose(pose: InspectionPose) { + pauseScriptedMotion() + if (pose === 'Free') return + const poses: Record, [number, number, number]> = { + Front: [-0.06, 0.12, 0], + Grazing: [-0.14, THREE.MathUtils.degToRad(52), 0], + Edge: [-0.05, Math.PI / 2, 0], + Back: [-0.06, Math.PI + 0.12, 0], + } + rotationTarget.set(...poses[pose]) +} + +function resetView() { + pauseScriptedMotion() + const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined + rotationTarget.set(-0.06, 0.12, 0) + cardRoot.rotation.copy(rotationTarget) + camera.position.set(0, 0, 8.2) + orbit.target.set(0, 0, 0) + orbit.update() + modeSelect.value = 'Inspect' + controls.target = 'Card' + fixtureSelect.value = 'David' + finishSelect.value = 'Holographic' + substrateSelect.value = 'Paper' + poseSelect.value = 'Front' + controls.finish = 'Holographic' + controls.substrate = 'Paper' + controls.finishStrength = 0.6 + controls.roughness = 0.23 + controls.normalStrength = 0.14 + controls.condition = 1 + controls.imperfectionSeed = 81251 + applyLightingPreset('Studio') + updateMaterial() + updateEdgeMaterial() + updateMode() + if (resetFixture) { + void loadFixture('David') + } else { + updateStatus() + } + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) +} + +async function loadFixture(nextFixture: FixtureName) { + const request = ++textureRequest + loadingElement.hidden = false + statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…` + const asset = fixtureAssets[nextFixture] + + try { + const results = await Promise.allSettled([ + loader.loadAsync(asset.artwork), + loader.loadAsync(asset.mask), + ]) + const [artworkResult, maskResult] = results + if (artworkResult.status === 'rejected') { + if (maskResult.status === 'fulfilled') maskResult.value.dispose() + throw artworkResult.reason + } + if (maskResult.status === 'rejected') { + artworkResult.value.dispose() + throw maskResult.reason + } + const artwork = artworkResult.value + const mask = maskResult.value + configureFrontTexture(artwork, THREE.SRGBColorSpace) + configureFrontTexture(mask, THREE.NoColorSpace) + + if (request !== textureRequest) { + artwork.dispose() + mask.dispose() + return + } + + const previousArtwork = frontMaterial.uniforms.artwork.value as THREE.Texture + const previousMask = frontMaterial.uniforms.finishMask.value as THREE.Texture + frontMaterial.uniforms.artwork.value = artwork + frontMaterial.uniforms.finishMask.value = mask + frontMaterial.uniformsNeedUpdate = true + activeFixture = nextFixture + customArtworkName = undefined + customMaskName = undefined + previousArtwork.dispose() + previousMask.dispose() + updateStatus() + } catch (error) { + if (request === textureRequest) { + fixtureSelect.value = activeFixture + if (mode !== 'Pack') { + statusElement.textContent = `Could not load ${nextFixture}: ${error instanceof Error ? error.message : String(error)}` + } + } + } finally { + if (request === textureRequest) loadingElement.hidden = true + } +} + +async function loadLocalTexture(file: File, target: 'artwork' | 'mask') { + const request = ++textureRequest + const objectUrl = URL.createObjectURL(file) + loadingElement.hidden = false + statusElement.textContent = `Loading ${target} from ${file.name}…` + + try { + const texture = await loader.loadAsync(objectUrl) + configureFrontTexture( + texture, + target === 'artwork' ? THREE.SRGBColorSpace : THREE.NoColorSpace, + ) + if (request !== textureRequest) { + texture.dispose() + return + } + + const uniformName = target === 'artwork' ? 'artwork' : 'finishMask' + const previousTexture = frontMaterial.uniforms[uniformName].value as THREE.Texture + frontMaterial.uniforms[uniformName].value = texture + frontMaterial.uniformsNeedUpdate = true + previousTexture.dispose() + if (target === 'artwork') { + customArtworkName = file.name + } else { + customMaskName = file.name + } + updateStatus() + } catch (error) { + if (request === textureRequest && mode !== 'Pack') { + statusElement.textContent = + `Could not load ${file.name}: ${error instanceof Error ? error.message : String(error)}` + } + } finally { + URL.revokeObjectURL(objectUrl) + if (request === textureRequest) loadingElement.hidden = true + } +} + +function startSweep() { + if (scriptedMotion) { + pauseScriptedMotion() + return + } + scriptedMotion = true + scriptStartedAt = clock.getElapsedTime() + scriptStartRotation.copy(cardRoot.rotation) + rotationTarget.copy(cardRoot.rotation) + scriptStartLight.copy(lightPosition) + scriptTargetLightX = scriptStartLight.x >= 0 ? -3.2 : 3.2 + poseSelect.value = 'Free' + sweepButton.textContent = 'Stop sweep' +} + +function resizeRenderer() { + const width = canvas.clientWidth + const height = canvas.clientHeight + const pixelRatio = Math.min(window.devicePixelRatio, 2) + const targetWidth = Math.floor(width * pixelRatio) + const targetHeight = Math.floor(height * pixelRatio) + if (canvas.width !== targetWidth || canvas.height !== targetHeight) { + renderer.setPixelRatio(pixelRatio) + renderer.setSize(width, height, false) + camera.aspect = width / height + camera.updateProjectionMatrix() + pack?.resize(width, height) + } +} + +function setCameraDistance(distance: number) { + const offset = camera.position.clone().sub(orbit.target) + if (offset.lengthSq() < 0.0001) offset.set(0, 0, 1) + offset.setLength(THREE.MathUtils.clamp(distance, orbit.minDistance, orbit.maxDistance)) + camera.position.copy(orbit.target).add(offset) +} + +function getPinchDistance() { + const pointers = [...activePointers.values()] + return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1]) +} + +canvas.addEventListener('pointerdown', (event) => { + if (mode === 'Pack') { + pack?.pointerDown(event) + return + } + if (orbit.enabled || event.button !== 0) return + pauseScriptedMotion() + activePointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY)) + canvas.setPointerCapture(event.pointerId) + if (activePointers.size === 1) { + dragging = true + poseSelect.value = 'Free' + lastPointer.set(event.clientX, event.clientY) + } else { + dragging = false + lastPinchDistance = getPinchDistance() + } +}) + +canvas.addEventListener('pointermove', (event) => { + if (mode === 'Pack') { + pack?.pointerMove(event) + return + } + const pointer = activePointers.get(event.pointerId) + if (!pointer) return + pointer.set(event.clientX, event.clientY) + if (activePointers.size >= 2) { + const pinchDistance = getPinchDistance() + if (pinchDistance && lastPinchDistance && pinchDistance > 0) { + const cameraDistance = camera.position.distanceTo(orbit.target) + setCameraDistance(cameraDistance * lastPinchDistance / pinchDistance) + } + lastPinchDistance = pinchDistance + return + } + if (!dragging) return + const dx = event.clientX - lastPointer.x + const dy = event.clientY - lastPointer.y + cardRoot.rotation.y += dx * 0.008 + cardRoot.rotation.x += dy * 0.008 + cardRoot.rotation.x = THREE.MathUtils.clamp(cardRoot.rotation.x, -1.25, 1.25) + rotationTarget.copy(cardRoot.rotation) + lastPointer.set(event.clientX, event.clientY) +}) + +canvas.addEventListener('pointerup', (event) => { + if (mode === 'Pack') { + pack?.pointerUp(event) + return + } + activePointers.delete(event.pointerId) + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId) + } + lastPinchDistance = getPinchDistance() + const remainingPointer = activePointers.values().next().value as THREE.Vector2 | undefined + dragging = remainingPointer !== undefined + if (remainingPointer) lastPointer.copy(remainingPointer) +}) + +canvas.addEventListener('pointercancel', (event) => { + if (mode === 'Pack') { + pack?.pointerUp(event, true) + return + } + activePointers.delete(event.pointerId) + lastPinchDistance = getPinchDistance() + const remainingPointer = activePointers.values().next().value as THREE.Vector2 | undefined + dragging = remainingPointer !== undefined + if (remainingPointer) lastPointer.copy(remainingPointer) +}) + +canvas.addEventListener('wheel', (event) => { + if (mode === 'Pack') { + event.preventDefault() + pack?.wheel(event) + return + } + if (orbit.enabled) return + event.preventDefault() + pauseScriptedMotion() + const distance = camera.position.distanceTo(orbit.target) + setCameraDistance(distance + event.deltaY * 0.006) +}, { passive: false }) + +canvas.addEventListener('lostpointercapture', (event) => { + if (mode === 'Pack') pack?.pointerUp(event, true) +}) +packPrimaryButton.addEventListener('click', () => { + if (pack) pack.primary() + else void preparePack() +}) +packRestartButton.addEventListener('click', () => pack?.restart()) +packSkipButton.addEventListener('click', () => pack?.skip()) + +modeSelect.addEventListener('change', () => { + pauseScriptedMotion() + updateMode() +}) +fixtureSelect.addEventListener('change', () => { + pauseScriptedMotion() + void loadFixture(fixtureSelect.value as FixtureName) +}) +substrateSelect.addEventListener('change', () => { + pauseScriptedMotion() + controls.substrate = substrateSelect.value as SubstrateName + updateMaterial() + updateEdgeMaterial() + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + updateStatus() +}) +finishSelect.addEventListener('change', () => { + pauseScriptedMotion() + controls.finish = finishSelect.value as FinishName + updateMaterial() + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + updateStatus() +}) +poseSelect.addEventListener('change', () => { + applyInspectionPose(poseSelect.value as InspectionPose) +}) +lightingPresetSelect.addEventListener('change', () => { + const preset = lightingPresetSelect.value as LightingPreset + if (preset === 'Custom') { + controls.lightingPreset = 'Custom' + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + return + } + applyLightingPreset(preset) +}) +lightTypeSelect.addEventListener('change', () => { + pauseScriptedMotion() + controls.lightType = lightTypeSelect.value as LightType + markLightingCustom() + updateLight() + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) +}) +artworkFileInput.addEventListener('change', () => { + const file = artworkFileInput.files?.[0] + artworkFileInput.value = '' + if (file) { + pauseScriptedMotion() + void loadLocalTexture(file, 'artwork') + } +}) +maskFileInput.addEventListener('change', () => { + const file = maskFileInput.files?.[0] + maskFileInput.value = '' + if (file) { + pauseScriptedMotion() + void loadLocalTexture(file, 'mask') + } +}) +experimentFileInput.addEventListener('change', () => { + const file = experimentFileInput.files?.[0] + experimentFileInput.value = '' + if (!file) return + void (async () => { + try { + const parsed: unknown = JSON.parse(await file.text()) + if (!isExperimentSnapshot(parsed)) { + statusElement.textContent = `${file.name} is not a supported card experiment.` + return + } + await applyExperimentSnapshot(parsed) + } catch (error) { + statusElement.textContent = + `Could not import ${file.name}: ${error instanceof Error ? error.message : String(error)}` + } + })() +}) +flipButton.addEventListener('click', () => { + pauseScriptedMotion() + poseSelect.value = 'Free' + const currentYaw = cardRoot.rotation.y + const normalized = THREE.MathUtils.euclideanModulo(currentYaw, Math.PI * 2) + rotationTarget.copy(cardRoot.rotation) + rotationTarget.y = currentYaw + + (normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI) +}) +resetButton.addEventListener('click', resetView) +sweepButton.addEventListener('click', startSweep) +controlsToggle.addEventListener('click', () => { + setControlsCollapsed(!topbar.classList.contains('controls-collapsed')) + requestAnimationFrame(resizeRenderer) +}) +orbit.addEventListener('start', pauseScriptedMotion) + +function updatePerformance(now: number, deltaMs: number) { + frameTimes.push(deltaMs) + if (frameTimes.length > 240) frameTimes.shift() + if (now - lastPerformanceUpdate < 1000 || frameTimes.length < 10) return + const sorted = [...frameTimes].sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length * 0.5)] + const p95 = sorted[Math.floor(sorted.length * 0.95)] + const worst = sorted[sorted.length - 1] + performanceElement.textContent = + `${renderer.domElement.width}×${renderer.domElement.height} · median ${median.toFixed(1)} ms · p95 ${p95.toFixed(1)} ms · worst ${worst.toFixed(1)} ms` + lastPerformanceUpdate = now +} + +function animate(frameTime: number) { + const deltaSeconds = lastFrameTime === undefined + ? 0 + : Math.min((frameTime - lastFrameTime) / 1000, 0.1) + if (lastFrameTime !== undefined) { + updatePerformance(frameTime, frameTime - lastFrameTime) + } + lastFrameTime = frameTime + const elapsed = clock.getElapsedTime() + resizeRenderer() + if (mode !== 'Pack') orbit.update() + + if (mode !== 'Pack' && !dragging && !scriptedMotion) { + cardRoot.rotation.x = THREE.MathUtils.damp(cardRoot.rotation.x, rotationTarget.x, 9, deltaSeconds) + cardRoot.rotation.y = THREE.MathUtils.damp(cardRoot.rotation.y, rotationTarget.y, 9, deltaSeconds) + cardRoot.rotation.z = THREE.MathUtils.damp(cardRoot.rotation.z, rotationTarget.z, 9, deltaSeconds) + } + + if (mode !== 'Pack' && scriptedMotion) { + const progress = (elapsed - scriptStartedAt) / 5 + if (progress >= 1) { + scriptedMotion = false + cardRoot.rotation.copy(scriptStartRotation) + rotationTarget.copy(scriptStartRotation) + lightPosition.copy(scriptStartLight) + syncLightControls() + updateLight() + sweepButton.textContent = 'Play sweep' + gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) + } else { + const eased = progress * progress * (3 - 2 * progress) + const wave = Math.sin(eased * Math.PI) + cardRoot.rotation.x = scriptStartRotation.x - 0.12 * wave + cardRoot.rotation.y = scriptStartRotation.y + THREE.MathUtils.degToRad(24) * wave + lightPosition.set( + THREE.MathUtils.lerp(scriptStartLight.x, scriptTargetLightX, wave), + scriptStartLight.y + wave * 0.8, + scriptStartLight.z, + ) + syncRuntimeLightPositions() + } + } + + if (mode === 'Pack') pack?.tick(frameTime) + renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera) +} + +renderer.setAnimationLoop(animate) +window.addEventListener('resize', resizeRenderer) +updateMaterial() +updateEdgeMaterial() +updateLight() +updateMode() +resetView() +loadingElement.hidden = true +updateStatus() diff --git a/sanctification-tcg/card-harness/src/packOpening.ts b/sanctification-tcg/card-harness/src/packOpening.ts new file mode 100644 index 0000000..20a0f16 --- /dev/null +++ b/sanctification-tcg/card-harness/src/packOpening.ts @@ -0,0 +1,582 @@ +import * as THREE from 'three' +import { cardSceneDimensions, createCardGeometry } from './cardGeometry' +import { createFoilWrapper } from './foilWrapper' +import { + applyEdgeMaterialControls, + applyMaterialControls, + createCardMaterial, + type FinishName, + type SubstrateName, +} from './cardMaterial' + +export type PackFixture = 'David' | 'Timothy' +export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted' + | 'revealing' | 'inspecting' | 'advancing' | 'complete' + +export const packContents: readonly { + fixture: PackFixture + finish: FinishName + substrate: SubstrateName +}[] = [ + { fixture: 'David', finish: 'Printed ink', substrate: 'Linen' }, + { fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' }, + { fixture: 'David', finish: 'Holographic', substrate: 'Metal' }, +] + +// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards. +const stackDepthSpacing = 0.055 +const cardSweepRadius = Math.hypot( + cardSceneDimensions.width, cardSceneDimensions.height, cardSceneDimensions.thickness, +) / 2 +// Clearance is based on the full corner sweep, not just the card's thickness or front pose. +const inspectionDepth = Math.max(3, cardSweepRadius + 0.5) +const inspectionCameraRetreat = inspectionDepth - 0.85 + +interface PackOptions { + canvas: HTMLCanvasElement + textures: Record + backMaterial: THREE.Material + normalMap: THREE.Texture + environment: THREE.CubeTexture + lightPosition: THREE.Vector3 + onChange: () => void +} + +interface Pose { + position: THREE.Vector3 + quaternion: THREE.Quaternion + scale: THREE.Vector3 +} + +interface Track { + object: THREE.Object3D + from: Pose + to: Pose +} + +interface Motion { + tracks: Track[] + startedAt: number + elapsed: number + duration: number + finish: () => void + progress?: (value: number) => void +} + +function pose(x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale = 1): Pose { + return { + position: new THREE.Vector3(x, y, z), + quaternion: new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), + scale: new THREE.Vector3(scale, scale, scale), + } +} + +function setPose(object: THREE.Object3D, value: Pose) { + object.position.copy(value.position) + object.quaternion.copy(value.quaternion) + object.scale.copy(value.scale) +} + +function track(object: THREE.Object3D, to: Pose): Track { + return { + object, + from: { + position: object.position.clone(), + quaternion: object.quaternion.clone(), + scale: object.scale.clone(), + }, + to, + } +} + +export class PackOpening { + readonly root = new THREE.Group() + readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100) + state: PackState = 'sealed' + paused = false + private readonly options: PackOptions + private readonly wrapper = createFoilWrapper() + private readonly cards + private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + private motion: Motion | undefined + private active = false + private index = 0 + private readonly revealed = new Set() + private completed = false + private zoom = 1 + private fitDistance = 9.6 + private cameraRetreat = 0 + private cameraTargetY = 0 + private openingProgress = 0 + private seamDrag: { id: number; x: number; progress: number; travel: number } | undefined + private readonly touch = new THREE.Vector3() + private touchTarget = 0 + private lastTick: number | undefined + private readonly pointers = new Map() + private tap: { id: number; x: number; y: number; at: number } | undefined + private pinchDistance: number | undefined + + constructor(options: PackOptions) { + this.options = options + this.root.name = 'PACK_PROTOTYPE' + this.root.position.y = 0.22 + this.root.visible = false + this.root.add(this.wrapper.root) + this.cards = packContents.map((spec) => { + const textures = options.textures[spec.fixture] + const material = createCardMaterial( + textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition, + ) + applyMaterialControls(material, { + ...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: 0.14, + environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251, + }) + const edge = new THREE.MeshPhysicalMaterial() + const object = createCardGeometry(material, options.backMaterial, edge) + const front = object.getObjectByName('CARD_FRONT')! + this.root.add(object) + return { object, material, edge, front } + }) + this.restart() + } + + get view() { + const number = `${this.index + 1} / ${this.cards.length}` + const spec = packContents[this.index] + const views: Record = { + sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' }, + opening: { + action: this.motion && !this.paused ? 'Opening…' : 'Finish opening', + status: this.openingProgress < 0.38 + ? `Tearing seal · ${Math.round(this.tearProgress * 100)}%` + : this.openingProgress < 0.52 ? 'Strip curling away' + : this.openingProgress < 0.62 ? 'Spreading the foil mouth' + : this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack', + hint: this.openingProgress < 0.38 + ? 'Pull right along the seam · release to hold · Finish opening resumes' + : 'Touch or wheel pauses motion · Skip finishes this step', + }, + stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' }, + lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' }, + lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' }, + revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' }, + inspecting: { + action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card', + status: `${number} · ${spec.fixture} · ${spec.substrate} · ${spec.finish}`, + hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue', + }, + advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' }, + complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' }, + } + return this.paused && this.motion + ? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' } + : views[this.state] + } + + get tearProgress() { return Math.min(1, this.openingProgress / 0.38) } + + syncLighting(source: THREE.ShaderMaterial) { + for (const { material } of this.cards) { + for (const name of ['studioEnvironment', 'environmentIntensity', 'lightMode', 'lightIntensity']) { + material.uniforms[name].value = source.uniforms[name].value + } + material.uniforms.lightColor.value.copy(source.uniforms.lightColor.value) + } + } + + setActive(active: boolean) { + if (!active) this.pause(performance.now()) + this.clearPointers() + this.active = active + this.root.visible = active + this.options.onChange() + } + + resize(width: number, height: number) { + if (width <= 0 || height <= 0) return + this.camera.aspect = width / height + this.camera.updateProjectionMatrix() + const tangent = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) + this.fitDistance = Math.max(9.6, 3.95 / (2 * tangent * this.camera.aspect)) + this.updateCamera() + } + + private updateCamera() { + this.camera.position.set(0, this.cameraTargetY, this.fitDistance * this.zoom + this.cameraRetreat) + this.camera.lookAt(0, this.cameraTargetY, 0) + } + + private stackPose(offset: number) { + return pose(offset * 0.045, -offset * 0.045, -0.08 - offset * stackDepthSpacing, 0, Math.PI, 0, 0.88) + } + + private reservePose(offset: number) { + const target = this.stackPose(offset) + target.position.x += 1.05 + target.position.y -= 0.4 + return target + } + + restart() { + this.clearPointers() + this.motion = undefined + this.paused = false + this.index = 0 + this.revealed.clear() + this.completed = false + this.zoom = 1 + this.cameraRetreat = 0 + this.cameraTargetY = 0 + this.openingProgress = 0 + this.touch.set(0, 0, 0) + this.touchTarget = 0 + this.lastTick = undefined + this.updateCamera() + this.wrapper.root.visible = true + setPose(this.wrapper.root, pose(0, 0, 0)) + this.wrapper.deform(0, 0, 0, this.touch) + this.cards.forEach((card, index) => { + card.object.visible = true + card.front.visible = false + applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 }) + setPose(card.object, this.stackPose(index)) + }) + this.setState('sealed') + } + + private shapeWrapper() { + const stripExit = (this.camera.position.z + 4) + * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2 + this.wrapper.deform(this.tearProgress, + THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52), + THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, stripExit) + } + + private setOpeningProgress(value: number) { + this.openingProgress = value + this.shapeWrapper() + const smooth = THREE.MathUtils.smoothstep + const settling = smooth(value, 0.87, 1) + const framing = smooth(value, 0.52, 0.76) * (1 - settling) + this.cameraRetreat = 8 * framing + this.cameraTargetY = 1.6 * framing + this.updateCamera() + this.cards.forEach((card, index) => { + const target = this.stackPose(index) + // All cards clear the mouth before the pouch moves aside. Depth spacing never changes. + target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling) + setPose(card.object, target) + }) + const aside = smooth(value, 0.83, 0.87) + const halfViewWidth = this.camera.position.z + * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + this.wrapper.root.position.x = -(halfViewWidth + 2) * aside + this.wrapper.root.visible = value < 0.87 + this.options.onChange() + } + + private open() { + const start = this.openingProgress + this.begin('opening', [], 3200 * (1 - start), () => { + this.wrapper.root.visible = false + this.setState('stackReady') + }, (value) => this.setOpeningProgress(THREE.MathUtils.lerp(start, 1, value))) + } + + private setState(state: PackState) { + this.state = state + const revealPhase = state === 'revealing' || state === 'inspecting' || state === 'advancing' + this.cards.forEach((card, index) => { + card.object.visible = state !== 'complete' && index >= this.index + card.front.visible = revealPhase && index === this.index + }) + if (state === 'inspecting' && !this.revealed.has(this.index)) { + this.revealed.add(this.index) + applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 }) + this.options.canvas.dispatchEvent(new CustomEvent('packreveal', { + detail: { index: this.index + 1, ...packContents[this.index] }, + })) + } + if (state === 'complete' && !this.completed) { + this.completed = true + this.options.canvas.dispatchEvent(new CustomEvent('packcomplete', { detail: { count: this.revealed.size } })) + } + this.options.onChange() + } + + private begin( + state: PackState, tracks: Track[], duration: number, finish: () => void, + progress?: (value: number) => void, + ) { + this.paused = false + this.motion = { + tracks, duration: this.reducedMotion.matches ? 70 : duration, + startedAt: performance.now(), elapsed: 0, finish, progress, + } + this.setState(state) + } + + primary() { + if (!this.active) return + this.clearPointers() + if (this.motion) { + if (this.paused) { + this.paused = false + this.motion.startedAt = performance.now() + this.options.onChange() + } + return + } + const card = this.cards[this.index] + switch (this.state) { + case 'sealed': + case 'opening': + this.open() + break + case 'stackReady': { + // This is the stack card itself, not a second lift actor or a cloned placeholder. + const tracks = [track(card.object, pose(0, 0.14, inspectionDepth, 0, Math.PI))] + for (let next = this.index + 1; next < this.cards.length; next++) { + tracks.push(track(this.cards[next].object, this.reservePose(next - this.index - 1))) + } + const startRetreat = this.cameraRetreat + this.begin('lifting', tracks, 620, () => this.setState('lifted'), (value) => { + // Camera framing follows the lift; updateCamera remains the sole camera transform writer. + this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, inspectionCameraRetreat, value) + this.updateCamera() + }) + break + } + case 'lifted': + this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820, + () => this.setState('inspecting')) + break + case 'inspecting': { + const halfViewWidth = (this.camera.position.z - inspectionDepth) + * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + const exitX = -Math.max(4.8, halfViewWidth + cardSweepRadius + 0.3) + // Stay on the safe plane even when advancing from an arbitrary manually rotated pose. + const tracks = [track(card.object, pose(exitX, 0.3, inspectionDepth, 0.1, -0.5, -0.18, 0.85))] + for (let next = this.index + 1; next < this.cards.length; next++) { + tracks.push(track(this.cards[next].object, this.stackPose(next - this.index - 1))) + } + const startRetreat = this.cameraRetreat + this.begin('advancing', tracks, 650, () => { + if (this.index === this.cards.length - 1) { + this.setState('complete') + } else { + this.index++ + this.setState('stackReady') + } + }, (value) => { + this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value) + this.updateCamera() + }) + break + } + default: + break + } + } + + skip() { + if (!this.active) return + this.clearPointers() + if (!this.motion) this.primary() + if (this.motion) this.finishMotion() + } + + private finishMotion() { + const motion = this.motion + if (!motion) return + for (const item of motion.tracks) setPose(item.object, item.to) + motion.progress?.(1) + this.motion = undefined + this.paused = false + motion.finish() + } + + tick(now: number) { + const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000)) + this.lastTick = now + if (!this.active) return + if (Math.abs(this.touch.z - this.touchTarget) > 0.001) { + this.touch.z = this.reducedMotion.matches ? this.touchTarget + : THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18)) + this.shapeWrapper() + } + const motion = this.motion + if (!motion || this.paused) return + const progress = THREE.MathUtils.clamp((motion.elapsed + now - motion.startedAt) / motion.duration, 0, 1) + if (progress >= 1) { + this.finishMotion() + return + } + const eased = progress * progress * (3 - 2 * progress) + for (const { object, from, to } of motion.tracks) { + object.position.lerpVectors(from.position, to.position, eased) + object.quaternion.slerpQuaternions(from.quaternion, to.quaternion, eased) + object.scale.lerpVectors(from.scale, to.scale, eased) + } + motion.progress?.(eased) + } + + private pause(now: number) { + const interrupted = this.motion !== undefined + this.tick(now) + if (this.motion && !this.paused) { + this.motion.elapsed += now - this.motion.startedAt + this.paused = true + this.options.onChange() + } + return interrupted + } + + pointerDown(event: PointerEvent) { + if (!this.active || event.button !== 0) return + const interrupted = this.pause(performance.now()) + this.pointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY)) + this.options.canvas.setPointerCapture(event.pointerId) + this.tap = this.pointers.size === 1 && !interrupted + ? { id: event.pointerId, x: event.clientX, y: event.clientY, at: performance.now() } + : undefined + this.pinchDistance = this.getPinchDistance() + if (this.pointers.size > 1) { + this.endSeamDrag() + this.touchTarget = 0 + return + } + if (this.state === 'sealed' || (this.state === 'opening' && this.openingProgress < 0.38)) { + const local = this.wrapperPoint(event.clientX, event.clientY) + if (local && Math.abs(local.x) < 1.8 && local.y > -2.24 && local.y < 2.5) { + this.touch.set(local.x, Math.min(local.y, 1.7), this.touch.z) + this.touchTarget = 1 + } + const seam = this.seamScreenBounds() + if (event.clientX >= seam.left - 18 && event.clientX <= seam.right + 18 + && Math.abs(event.clientY - seam.y) <= Math.max(22, seam.height)) { + this.motion = undefined + this.seamDrag = { id: event.pointerId, x: event.clientX, + progress: this.openingProgress, travel: Math.max(80, (seam.right - seam.left) * 0.75) } + this.paused = false + } + } + } + + pointerMove(event: PointerEvent) { + const pointer = this.pointers.get(event.pointerId) + if (!pointer) return + const dx = event.clientX - pointer.x + const dy = event.clientY - pointer.y + pointer.set(event.clientX, event.clientY) + if (this.tap && Math.hypot(event.clientX - this.tap.x, event.clientY - this.tap.y) > 6) { + this.tap = undefined + } + if (this.pointers.size >= 2) { + const distance = this.getPinchDistance() + if (distance && this.pinchDistance) this.setZoom(this.zoom * this.pinchDistance / distance) + this.pinchDistance = distance + } else if (this.seamDrag?.id === event.pointerId) { + const drag = this.seamDrag + const progress = THREE.MathUtils.clamp(drag.progress + + Math.max(0, event.clientX - drag.x) / drag.travel * 0.38, this.openingProgress, 0.38) + if (progress > this.openingProgress && !this.tap) { + if (this.state === 'sealed') this.setState('opening') + this.setOpeningProgress(progress) + } + } else if (this.state === 'inspecting') { + const object = this.cards[this.index].object + object.rotation.y += dx * 0.008 + object.rotation.x = THREE.MathUtils.clamp(object.rotation.x + dy * 0.008, -1.25, 1.25) + } else if (this.touchTarget) { + const local = this.wrapperPoint(event.clientX, event.clientY) + if (local) { + this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68) + this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7) + this.shapeWrapper() + } + } + } + + pointerUp(event: PointerEvent, cancelled = false) { + if (!this.pointers.has(event.pointerId)) return + const tearing = this.seamDrag?.id === event.pointerId + if (tearing) this.endSeamDrag() + this.touchTarget = 0 + const tap = this.tap + this.tap = undefined + this.pointers.delete(event.pointerId) + this.pinchDistance = this.getPinchDistance() + if (this.options.canvas.hasPointerCapture(event.pointerId)) { + this.options.canvas.releasePointerCapture(event.pointerId) + } + if (tearing && !cancelled && this.tearProgress >= 1) { + this.primary() + return + } + if (!cancelled && tap?.id === event.pointerId && performance.now() - tap.at < 600 + && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) <= 6) this.primary() + } + + wheel(event: WheelEvent) { + event.preventDefault() + this.pause(performance.now()) + this.clearPointers() + const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 240 : 1) + this.setZoom(this.zoom * Math.exp(pixels * 0.001)) + } + + private setZoom(zoom: number) { + this.zoom = THREE.MathUtils.clamp(zoom, 0.62, 1.45) + this.updateCamera() + } + + private getPinchDistance() { + const pointers = [...this.pointers.values()] + return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1]) + } + + private clearPointers() { + this.endSeamDrag() + this.touchTarget = 0 + this.tap = undefined + const ids = [...this.pointers.keys()] + this.pointers.clear() + for (const id of ids) { + if (this.options.canvas.hasPointerCapture(id)) this.options.canvas.releasePointerCapture(id) + } + this.pinchDistance = undefined + } + + private endSeamDrag() { + if (!this.seamDrag) return + this.seamDrag = undefined + if (this.state === 'opening' && !this.motion) this.paused = true + this.options.onChange() + } + + private seamScreenBounds() { + const rect = this.options.canvas.getBoundingClientRect() + this.root.updateMatrixWorld(true) + this.camera.updateMatrixWorld(true) + const screen = (x: number, y: number) => { + const point = this.wrapper.root.localToWorld(new THREE.Vector3(x, y, -0.09)).project(this.camera) + return { x: rect.left + (point.x + 1) * rect.width / 2, + y: rect.top + (1 - point.y) * rect.height / 2 } + } + const left = screen(-1.68, 2.12) + const right = screen(1.68, 2.12) + return { left: left.x, right: right.x, y: left.y, height: Math.abs(screen(0, 1.97).y - screen(0, 2.28).y) } + } + + private wrapperPoint(x: number, y: number) { + const rect = this.options.canvas.getBoundingClientRect() + this.camera.updateMatrixWorld(true) + this.root.updateMatrixWorld(true) + const ray = new THREE.Raycaster() + ray.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, + 1 - (y - rect.top) / rect.height * 2), this.camera) + const point = ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.28), new THREE.Vector3()) + return point ? this.wrapper.root.worldToLocal(point) : undefined + } +} diff --git a/sanctification-tcg/card-harness/src/style.css b/sanctification-tcg/card-harness/src/style.css new file mode 100644 index 0000000..ce804a5 --- /dev/null +++ b/sanctification-tcg/card-harness/src/style.css @@ -0,0 +1,496 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f3efe5; + background: #080a0e; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +button, +select { + color: inherit; + font: inherit; +} + +.app-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: 100%; + height: 100%; + background: + radial-gradient(circle at 50% -20%, rgba(108, 127, 160, 0.18), transparent 45%), + #080a0e; +} + +.topbar { + z-index: 3; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 82px; + padding: 14px 20px; + border-bottom: 1px solid rgba(227, 215, 181, 0.16); + background: rgba(11, 14, 20, 0.92); + backdrop-filter: blur(18px); +} + +.title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.controls-toggle { + display: none; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.controls-toggle-icon { + width: 12px; + color: #d6b860; + font-size: 16px; + line-height: 1; +} + +.eyebrow { + margin: 0 0 3px; + color: #b8a979; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(20px, 2.2vw, 30px); + font-weight: 500; + letter-spacing: 0.01em; +} + +.toolbar { + display: flex; + flex: 1 1 720px; + flex-wrap: wrap; + min-width: 0; + align-items: flex-end; + justify-content: flex-end; + gap: 8px; +} + +.toolbar label { + display: grid; + flex: 1 1 110px; + min-width: 100px; + gap: 4px; + color: #9da3ad; + font-size: 11px; + letter-spacing: 0.04em; +} + +select, +button { + height: 34px; + border: 1px solid rgba(227, 215, 181, 0.2); + border-radius: 7px; + background: #171b23; + outline: none; +} + +select { + width: 100%; + min-width: 0; + padding: 0 28px 0 10px; +} + +button { + padding: 0 13px; + cursor: pointer; +} + +button:hover, +select:hover { + border-color: rgba(218, 186, 102, 0.62); + background: #202630; +} + +button:focus-visible, +select:focus-visible { + outline: 2px solid #d6b860; + outline-offset: 2px; +} + +.viewport-shell { + position: relative; + min-height: 0; +} + +#scene { + display: block; + width: 100%; + height: 100%; + cursor: grab; + touch-action: none; +} + +#scene:active { + cursor: grabbing; +} + +#scene.camera-target { + cursor: move; +} + +.status-panel, +.hint, +.loading { + position: absolute; + pointer-events: none; + border: 1px solid rgba(227, 215, 181, 0.14); + background: rgba(10, 13, 18, 0.76); + backdrop-filter: blur(12px); +} + +.status-panel { + top: 16px; + left: 16px; + display: grid; + gap: 4px; + max-width: calc(100% - 32px); + padding: 9px 12px; + border-radius: 8px; + color: #d8d2c2; + font-size: 12px; +} + +#performance { + color: #89909b; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; +} + +.hint { + bottom: 16px; + left: 50%; + padding: 8px 12px; + border-radius: 999px; + color: #aeb4bc; + font-size: 12px; + transform: translateX(-50%); + white-space: nowrap; +} + +.loading { + z-index: 4; + top: 50%; + left: 50%; + padding: 12px 16px; + border-radius: 9px; + transform: translate(-50%, -50%); +} + +footer { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 8px 16px; + border-top: 1px solid rgba(227, 215, 181, 0.12); + color: #777f8a; + background: #0b0e14; + font-size: 11px; +} + +footer a { + color: #bba96e; +} + +.lil-gui.root { + --background-color: rgba(16, 20, 27, 0.94); + --widget-color: #252c37; + --hover-color: #303946; + --focus-color: #3b4655; + --text-color: #ede8dc; + --number-color: #e1c56e; + top: 98px; + right: 16px; +} + +.pack-controls[hidden] { + display: none; +} + +.pack-controls { + position: absolute; + bottom: max(12px, env(safe-area-inset-bottom)); + left: 50%; + display: grid; + gap: 8px; + width: min(440px, calc(100% - 24px)); + padding: 10px 12px; + border: 1px solid rgba(227, 215, 181, 0.22); + border-radius: 12px; + background: rgba(10, 13, 18, 0.9); + backdrop-filter: blur(12px); + transform: translateX(-50%); + text-align: center; +} + +#pack-status { + color: #d8d2c2; + font-size: 12px; +} + +#pack-tear { + appearance: none; + width: 100%; + height: 3px; + border: 0; + border-radius: 2px; + overflow: hidden; + background: #293039; + accent-color: #bba36a; +} + +#pack-tear::-webkit-progress-bar { + background: #293039; +} + +#pack-tear::-webkit-progress-value { + background: #bba36a; +} + +#pack-tear::-moz-progress-bar { + background: #bba36a; +} + +.pack-actions { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; +} + +.pack-actions button { + min-height: 44px; + height: auto; + padding: 8px 12px; +} + +#pack-primary { + border-color: #9b8750; + background: #393222; + color: #f5e5b4; + font-weight: 600; +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +.pack-mode .toolbar > :not(:first-child) { + display: none; +} + +.pack-mode .toolbar { + flex: 0 1 180px; +} + +.pack-mode .hint { + bottom: 118px; + max-width: calc(100% - 24px); + text-align: center; + white-space: normal; +} + +.pack-mode .status-panel { + max-width: min(440px, calc(100% - 32px)); +} + +@media (max-width: 1120px) { + .topbar { + align-items: flex-start; + } + + .toolbar { + flex-basis: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 880px) { + .topbar { + gap: 10px; + padding: 10px 12px; + } + + .title-row { + width: 100%; + } + + .controls-toggle { + display: inline-flex; + } + + .toolbar { + width: 100%; + justify-content: flex-start; + } + + .topbar.controls-collapsed { + min-height: 0; + } + + .topbar.controls-collapsed .toolbar { + display: none; + } + + .toolbar label { + flex-basis: calc(33.333% - 6px); + } + + .toolbar button { + flex: 1 1 120px; + } + + .hint { + bottom: 10px; + max-width: calc(100% - 24px); + overflow: hidden; + text-overflow: ellipsis; + } + + .lil-gui.root { + top: 210px; + } + + .compact-controls .lil-gui.root { + top: 74px; + } +} + +@media (max-width: 560px) { + .topbar { + gap: 8px; + } + + .toolbar { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .toolbar label { + width: 100%; + min-width: 0; + } + + .toolbar button { + width: 100%; + min-width: 0; + padding-inline: 8px; + } + + .lil-gui.root { + top: 288px; + } + + .pack-mode .toolbar { + display: flex; + } + + .pack-mode .status-panel { + top: 8px; + left: 12px; + padding: 6px 9px; + font-size: 10px; + } + + .pack-mode #performance { + font-size: 9px; + } + + .pack-mode .hint { + bottom: 114px; + padding: 5px 9px; + font-size: 10px; + } + + .pack-controls { + gap: 6px; + padding: 8px; + } + + #pack-status { + font-size: 11px; + } + + .pack-actions { + gap: 6px; + } + + .pack-actions button { + padding-inline: 10px; + font-size: 12px; + } +} + +@media (max-height: 540px) and (min-aspect-ratio: 4/3) { + .pack-controls { + right: 12px; + left: auto; + width: min(280px, 32vw); + padding: 8px; + transform: none; + } + + .pack-actions { + grid-template-columns: 1fr 1fr; + gap: 6px; + } + + .pack-actions button { + padding-inline: 8px; + font-size: 12px; + } + + #pack-primary { + grid-row: 1; + grid-column: 1 / -1; + } + + #pack-status { + font-size: 11px; + } + + .pack-mode .hint { + right: 12px; + bottom: 170px; + left: auto; + width: min(280px, 32vw); + font-size: 10px; + transform: none; + } + + .pack-mode .status-panel { + max-width: 28vw; + font-size: 10px; + } +} diff --git a/sanctification-tcg/card-harness/tsconfig.json b/sanctification-tcg/card-harness/tsconfig.json new file mode 100644 index 0000000..206b5e0 --- /dev/null +++ b/sanctification-tcg/card-harness/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/sanctification-tcg/common.png b/sanctification-tcg/common.png new file mode 100644 index 0000000..a6e3b95 Binary files /dev/null and b/sanctification-tcg/common.png differ diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md new file mode 100644 index 0000000..adaf892 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md @@ -0,0 +1,124 @@ +--- +name: flutter_scene-idioms +version: 4 +description: Write correct flutter_scene code. Use this whenever building 3D with the flutter_scene Dart/Flutter engine (rendering a scene, geometry, materials, lighting, loading a .glb model, animation, custom shaders). It corrects the wrong assumptions models carry from three.js, Godot, and Unity, and names the APIs and traps that are specific to this engine. +--- + +# Building with flutter_scene + +flutter_scene is a realtime 3D engine for Flutter, built on Flutter GPU. It has a retained scene graph (`Scene` holds `Node`s, nodes carry a `Mesh`), physically based materials, image-based lighting, and a deep post-processing stack. + +**The one thing to internalize: this is not three.js, Godot, or Unity, and the API diverges from all three in specific ways.** Most first-attempt failures come from reaching for another engine's spelling. The corrections below are the highest-value part of this skill; read them before writing code. + +## Do not reach for these (they do not exist or will break the build) + +- **Not the master channel.** flutter_scene runs on **Flutter 3.47 stable or newer**. Do not run `flutter channel master`; it resolves worse, not better. +- **Not `--enable-impeller`, not `--enable-experiment=native-assets`.** The run flag is **`--enable-flutter-gpu`** and nothing else. `--enable-experiment=native-assets` actively breaks the build on Dart 3.10+. +- **Not `package:vector_math/vector_math_64.dart`.** flutter_scene uses **`package:vector_math/vector_math.dart`**. The `_64` types are a different, incompatible `Vector3`. +- **Not `Node.fromAsset(...)`, not `loadModel(...)`.** Load a preprocessed model with **`loadScene('assets/x.glb')`** (returns `Future`), or a runtime glTF with `Node.fromGlbAsset` / `Node.fromGlbBytes`. +- **Not a hand-rolled `CustomPainter` + `Ticker`.** Display a scene with the **`SceneView`** widget; it drives the per-frame loop for you. +- **Not `node.position.set(x, y, z)`.** See transforms below. +- **Not `.model` files or `buildModels`.** The offline format is `.fsceneb`, produced by the `flutter_scene:init` build hook; you load it by source path with `loadScene`. +- **Not the removed `Environment` class.** Environment lighting is `EnvironmentMap` on `Scene.environment`. + +## Setup + +```sh +flutter pub add flutter_scene +dart run flutter_scene:init # installs the build hook, sets up assets +flutter run --enable-flutter-gpu # native; add -d chrome for web +``` + +`flutter_scene:init` is required setup, not optional. Rendering is gated on `Scene.initializeStaticResources()`; until it completes the engine prints "Flutter Scene is not ready to render. Skipping frame." + +## Minimal scene (this compiles as-is) + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_scene/scene.dart'; +import 'package:vector_math/vector_math.dart' as vm; + +void main() => runApp(const MaterialApp(home: CubeView())); + +class CubeView extends StatefulWidget { + const CubeView({super.key}); + @override + State createState() => _CubeViewState(); +} + +class _CubeViewState extends State { + final Scene scene = Scene(); + bool ready = false; + + @override + void initState() { + super.initState(); + // Geometry and materials touch the shader bundle, so build them only + // after the engine's static resources are up. + Scene.initializeStaticResources().then((_) { + scene.add(Node( + mesh: Mesh(CuboidGeometry(vm.Vector3(1, 1, 1)), PhysicallyBasedMaterial()), + )); + if (mounted) setState(() => ready = true); + }); + } + + @override + Widget build(BuildContext context) { + if (!ready) return const SizedBox.expand(); + return SceneView(scene, camera: PerspectiveCamera(position: vm.Vector3(2, 2, -4))); + } +} +``` + +An unset `Scene.environment` still gives image-based lighting (a default studio map is resolved at render), so a bare `PhysicallyBasedMaterial()` is lit without any light setup. + +## Choosing declarative or imperative + +flutter_scene has two ways to build a scene, and picking the wrong one is a structural decision that is expensive to undo later. Choose up front by what the app does, not by which reads nicer. + +**Declarative (inline widgets).** Describe the scene as Flutter widgets under `SceneView.declarative(children: [...])`, using `SceneMesh`, `SceneNode`, and `SceneModel`. Flutter's own rebuild diffing keeps the rendered scene in sync with your widget state, the same way it keeps the UI in sync. Reach for this when app state maps cleanly onto a fixed set of objects on screen and nothing is simulation-like, for example a product configurator where a few `SceneMesh`es track some `setState` values. + +**Imperative (retained scene graph).** Own a `Scene`, add `Node`s, attach `Component`s, and display it with `SceneView(scene, camera: ..., onTick: ...)`. Track state the way you would in another game engine. The cleanest shape is a plain Dart `Game` class that owns the `Scene` and holds the game state, with the scene build and per-frame tick routed into it, and behavior living in custom `Component`s attached to nodes that the engine runs through the component lifecycle hooks. Reach for this for anything with real simulation. + +**Go imperative when** the scene has complex physics, network replication, a character walking around, or procedural generation. Any one of these means imperative. +**Stay declarative when** state maps directly to a fixed set of shown objects and nothing ticks or simulates. + +The two interoperate. A mostly-declarative scene can drop to an imperative node where it needs one, and an imperative scene can mount declarative subtrees. See `references/architecture.md` for the `Game`-class pattern, component-driven nodes, and the hybrid seam. + +## The API shape (where it diverges from what you expect) + +**Transforms.** `Node` has `position`, `rotation` (a `Quaternion`), and `scale`, but they are whole-value get/set, not the mutable spelling other engines use. Assign the whole vector (`node.position = vm.Vector3(0, 1, 0)` or `node.position += ...`). The getters return copies, so `node.position.x = 5` does nothing and throws in debug. For a raw matrix edit use `node.localTransform = matrix` or `node.mutateLocalTransform((m) => m.translateByVector3(...))`; a bare in-place edit of `node.localTransform` never moves the node, because the cache is not told. + +**Geometry.** Ten built-in primitives (`CuboidGeometry`, `SphereGeometry`, `IcosphereGeometry`, `CylinderGeometry` with separate top/bottom radii so cones are free, `CapsuleGeometry`, `TorusGeometry`, `PlaneGeometry`, `DiscGeometry`, `RingGeometry`, `WedgeGeometry`), plus swept geometry (`ExtrudeGeometry`, `TubeGeometry`, `RibbonGeometry`), lines (`PolylineGeometry`), and `GeometryBuilder`/`MeshData` for custom meshes. Do not hand-pack a `ByteData` vertex buffer before checking these. + +**Materials.** `PhysicallyBasedMaterial` (base color, metallic, roughness, normal, emissive, plus clearcoat/sheen/transmission/etc.), `UnlitMaterial`, `ShaderMaterial` for custom shaders. Texture slots take a `TextureSource` (from `loadTexture(path)`), not a raw `gpu.Texture`. + +**Camera.** `PerspectiveCamera(position: ..., target: ...)`. There is no orthographic camera built in. + +## What you are probably underestimating (it is all here) + +Models trained on older or thinner information assume flutter_scene has no lighting, no shadows, and no post-processing. It has all of it. Before hand-rolling any of these, know they exist: **directional/point/spot/area lights, shadows (PCSS, contact shadows), GTAO ambient occlusion, screen-space reflections, parallax-corrected reflection probes, SSGI, depth of field, god rays, fog, auto exposure, LUT color grading, bloom, lens flares, MSAA/SMAA/FXAA, tone mapping, instancing, LOD.** See `references/what-exists.md` for the full surface with the class names. + +## Traps that fail silently (wrong pixels, no error) + +- **Custom `ShaderMaterial` output is linear HDR premultiplied by alpha.** No tone mapping or gamma in your shader; the `ResolvePass` applies exposure, tone mapping, and the display transform. Linearize sRGB texture samples yourself. See `MATERIALS.md`. +- **Never hand-roll a per-triangle winding flip to fix glTF orientation.** The importers handle the coordinate conversion; a manual flip leaves normals and IBL wrong. +- **Do not emit a vertex buffer at the wrong stride.** Unskinned is 72 bytes/vertex, skinned is 104; the attribute order is fixed. Use `GeometryBuilder`, do not guess the layout. + +## More depth + +- `references/architecture.md` for the declarative-vs-imperative choice in depth, the `Game`-class pattern, component-driven nodes, and hybrid interop. +- `references/what-exists.md` for the full API surface (the false-absence fix). +- `references/traps.md` for the complete silent-failure list. +- The repo-root `MATERIALS.md` for the custom-shader contract. + +## Keeping this skill current + +This skill ships inside the flutter_scene package, so upgrading flutter_scene can carry a newer revision of it than the copy installed in the project. To check, run: + +```sh +dart run flutter_scene:skills --check +``` + +It reports the installed and bundled skill versions and exits non-zero when an update is available. If the installed flutter_scene ships a newer skill than what is installed, tell the user, since they are working against out-of-date guidance, and offer to update it with `dart run flutter_scene:skills` (which touches only the skill, not their build hook or pubspec). Worth a check when you start substantial flutter_scene work or when the user mentions upgrading the package. \ No newline at end of file diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/architecture.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/architecture.md new file mode 100644 index 0000000..cd2f025 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/architecture.md @@ -0,0 +1,297 @@ +# Structuring a flutter_scene app + +Which API to build the scene with, and how to combine them. The short version is in SKILL.md; +this is the depth, with patterns that compile against 0.22.0. + +flutter_scene exposes the same scene graph two ways. The declarative widgets describe it as Flutter +widgets that rebuild-diff into the graph; the imperative API hands you the retained `Scene`/`Node`/ +`Component` graph directly. They are not competing renderers, they drive the same engine. The choice +is about how your app tracks state, and it is worth making deliberately because reworking a large +scene from one to the other is a rewrite. + +--- + +## The decision + +Go **declarative** when app state maps directly onto a fixed set of shown objects and nothing +simulates. A product configurator, a data-driven diagram, a few models whose transforms follow some +`setState` values. Flutter already owns the state, and the widgets keep the scene tracking it for +free. + +Go **imperative** when the scene simulates. Complex physics, network replication, a character +walking around under input, procedural generation. Any one of these means imperative. Here the +scene's state is the app's state, it changes every frame, and you want to own the loop rather than +express each frame as a widget rebuild. + +If you are unsure, ask whether anything in the scene changes on its own between user actions. If yes, +imperative. If the scene only changes when the user changes a value, declarative. + +--- + +## Declarative + +`SceneView.declarative` owns an internal `Scene`; its `children` are the whole scene description. + +```dart +class Configurator extends StatefulWidget { + const Configurator({super.key}); + @override + State createState() => _ConfiguratorState(); +} + +class _ConfiguratorState extends State { + // Build engine objects once, not per rebuild. Constructing GPU resources + // every build is the main performance hazard of the declarative layer. + final Geometry _geometry = CuboidGeometry(vm.Vector3(1, 1, 1)); + final PhysicallyBasedMaterial _material = PhysicallyBasedMaterial(); + double _spin = 0; + + @override + Widget build(BuildContext context) { + return Column(children: [ + Expanded( + child: SceneView.declarative( + camera: PerspectiveCamera(position: vm.Vector3(2, 2, -4)), + children: [ + SceneMesh( + geometry: _geometry, + material: _material, + rotation: vm.Quaternion.axisAngle(vm.Vector3(0, 1, 0), _spin), + ), + ], + ), + ), + Slider( + value: _spin, + max: 6.28, + onChanged: (v) => setState(() => _spin = v), + ), + ]); + } +} +``` + +The scene tracks widget state through the normal rebuild path. Note two things the example shows: +engine objects (`geometry`, `material`) are created once and held as fields (they are diffed by +identity, and rebuilding them every frame is the classic mistake), while cheap value props +(`rotation`) are fine to pass fresh each build. + +Declarative building blocks (all under `SceneView.declarative` or a `SceneView` with `children`): + +- `SceneMesh(geometry:, material:, ...)` a node with a mesh. +- `SceneNode(...)` a bare transform node, for grouping children. +- `SceneModel('assets/x.glb', animations: [...])` a loaded model (runtime glTF path). +- `SceneSubtree(parent:, children:)` mounts children under a given imperative `Node`. +- Every node widget takes `position`/`rotation`/`scale` (or a full `transform`), `visible`, + `components:` (attach imperative `Component`s), `controller:` (a `SceneNodeController` handle), and + `children:`. + +--- + +## Imperative + +Own the `Scene`, add `Node`s, attach `Component`s, display with `SceneView(scene, camera:, onTick:)`. +For anything beyond a demo, do not scatter this across a `StatefulWidget`. Put it in a plain Dart +class that owns the scene and the game state, and keep the widget thin. + +```dart +// Pure Dart, no Flutter import. Owns the scene and the game state. +class Game { + final Scene scene = Scene(); + late final Node player; + + Future load() async { + await Scene.initializeStaticResources(); + + // The camera lives in the scene as a node, not on the widget. A camera + // node's transform is its view: the translation is the eye, local +Z is + // the look direction, +Y is up. lookAtFrom sets both at once, so there is + // no view-matrix math to hand-roll. + final cameraNode = Node() + ..addComponent(CameraComponent(activateOnMount: true)) + ..lookAtFrom(vm.Vector3(0, 3, -8), vm.Vector3.zero()); + scene.add(cameraNode); + // activateOnMount makes this the scene's primary camera when the node + // mounts, so SceneView needs no `camera:` argument. (The first mounted + // camera auto-promotes anyway; this states the intent explicitly, and is + // how you pick one when several cameras exist.) + + player = Node(mesh: Mesh(CuboidGeometry(vm.Vector3(1, 1, 1)), + PhysicallyBasedMaterial())); + player.addComponent(PlayerController()); + scene.add(player); + } + + // Per-frame app logic that is not tied to one node. Component updates run + // on their own (see below), so this is for whole-game concerns. + void tick(double dt) { + // advance timers, spawn waves, read input, etc. + } +} +``` + +```dart +// Thin widget: builds the game, forwards ticks, renders the scene. +class GameView extends StatefulWidget { + const GameView({super.key}); + @override + State createState() => _GameViewState(); +} + +class _GameViewState extends State { + final Game game = Game(); + bool _ready = false; + + @override + void initState() { + super.initState(); + game.load().then((_) { + if (mounted) setState(() => _ready = true); + }); + } + + @override + Widget build(BuildContext context) { + if (!_ready) return const SizedBox.expand(); + // No `camera:` here: the view resolves the scene's active camera, which is + // the CameraComponent added in Game.load. Resolution order is the explicit + // `camera:` (absent), then `cameraBuilder`, then `scene.camera` (the active + // CameraComponent), then a default camera. + return SceneView( + game.scene, + onTick: (elapsed, dt) => game.tick(dt), + ); + } +} +``` + +### The active camera + +The scene owns which camera is active, and there are three levers: + +- **`CameraComponent(activateOnMount: true)`** (above) selects this camera when its node mounts. +- **`cameraComponent.makeActive()`** switches to it at runtime, for example a chase-cam to a + cutscene camera. Before its node mounts the choice is deferred and applied on mount. +- **`scene.camera = someCamera`** sets any `Camera` as the override directly, and `scene.camera` + reads the active one back. + +With no camera set at all, the first mounted `CameraComponent` auto-promotes, and a scene with none +still renders through a default camera. Move or rotate a `CameraComponent`'s node to move the view; +the `NodeCamera` reads the node's world transform live each frame. Aim it with `node.lookAt(target)` +(rotate toward a world point) or `node.lookAtFrom(eye, target)` (position and aim in one call); +Z is +the forward axis, so the same helpers aim lights and imported models. A follow-cam is then a one-line +component that calls `node.lookAtFrom(...)` in `update` each frame. + +### Camera controllers (interactive cameras) + +For a user-controlled camera, do not hand-roll the drag/scroll/key math: attach a camera controller +component to the camera node. `OrbitCameraController` (turntable around a target, drag rotates, scroll +dollies), `FlyCameraController` (WASD + drag free flight, `moveVertical: false` gives grounded +first-person), and `FollowCameraController` (third-person that eases behind a target node). Each holds +the camera state, eases toward it with frame-rate-independent smoothing, clamps pitch so the view +never flips, and writes the node via `lookAtFrom`. + +Wire input with the `CameraControls` widget wrapping the view; it forwards Flutter gestures and keys +to the controller. `SceneView` itself has no camera-input knobs, so nothing camera-specific leaks into +it. + +```dart +final camera = Node() + ..addComponent(CameraComponent(activateOnMount: true)) + ..addComponent(OrbitCameraController(target: vm.Vector3.zero(), distance: 8)); +scene.add(camera); + +// In build: +return CameraControls( + controller: camera.getComponent()!, + child: SceneView(scene), +); +``` + +The controllers also expose intent methods (`orbitBy`, `dollyBy`, `panBy`, `look`), so an app with its +own input handling can drive them without the widget. + +### Behavior lives in components, not in the tick + +The bulk of per-object logic should be custom `Component`s, not a giant `onTick`. A component is +attached to a node and the engine runs it through the lifecycle. Crucially, component ticks are +driven automatically by the render path, so you do not call them yourself, and `onTick` is only for +game-wide concerns that do not belong to a single node. + +```dart +class PlayerController extends Component { + vm.Vector3 velocity = vm.Vector3.zero(); + + @override + void onMount() { + // node is available here; wire up input, cache references. + } + + @override + void update(double deltaSeconds) { + // `node` is the node this component is attached to. + node.mutateLocalTransform( + (m) => m.translateByVector3(velocity * deltaSeconds), + ); + } +} +``` + +Component lifecycle hooks (subclass `Component`, override what you need): + +- `onAttach()` added to a node, before it is in a live scene. +- `Future onLoad()` async setup (await assets); mount waits for it. +- `onMount()` the node entered a live scene; `node` is usable. +- `update(double deltaSeconds)` per rendered frame. +- `fixedUpdate(double fixedDt)` fixed-step, driven by the physics accumulator when a `PhysicsWorld` + is present. Put physics-coupled logic here, not in `update`. +- `onUnmount()` / `onDetach()` teardown. + +This is the structure that scales. A character is a node with a controller component; an enemy is a +node with an AI component; a pickup is a node with a trigger component. The `Game` class holds what +is genuinely global (score, wave state, the input map), and everything spatial is a component on a +node. + +--- + +## Hybrid interop + +The two APIs share one graph, so you can mix them at the seam that suits the app. + +**Declarative shell, imperative pockets.** A declarative node accepts `components:`, so an otherwise +declarative scene can attach imperative behavior to any node without leaving the widget tree. + +```dart +SceneMesh( + geometry: _geometry, + material: _material, + components: [Spinner()], // a custom Component, ticked by the engine +) +``` + +**Imperative scene, declarative subtrees.** `SceneView(scene, children: [...])` mounts declarative +widgets over an app-owned scene. Use `SceneSubtree(parent: someNode, children: [...])` to attach a +declarative subtree under a specific imperative node, for example UI-like markers that follow a +game object. + +```dart +SceneView( + game.scene, + camera: PerspectiveCamera(position: vm.Vector3(0, 3, -8)), + children: [ + SceneSubtree( + parent: game.player, + children: [SceneModel('assets/hat.glb')], + ), + ], +) +``` + +**Reaching an imperative node from a declarative widget.** Pass a `SceneNodeController` as +`controller:` and read `controller.node` for the managed `Node` (null while unmounted). This is the +escape hatch when a declarative node needs an imperative handle for a one-off operation. + +The rule of thumb: pick the mode that matches how the *majority* of the scene is driven, then use the +seam above for the exceptions. Do not build a whole simulation out of declarative widgets to avoid +the imperative API, and do not hand-roll a diffing layer over the imperative graph to avoid the +declarative one. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/traps.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/traps.md new file mode 100644 index 0000000..aaa2384 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/traps.md @@ -0,0 +1,602 @@ +# flutter_scene silent-failure traps + +Mistakes that produce wrong pixels with no exception and no console message. Each entry gives the +mistake, what you see, and what to do instead. Sorted worst-first (most likely to hit, hardest to +diagnose from the symptom). + +Some of these are now caught by the engine in version 0.22.0. Those are tagged **[0.22.0 catches +this]** with what the engine does, so if you see that error you know what it means. The rest are +still silent, so you have to recognize them yourself. + +--- + +## 1. Editing a transform in place instead of assigning it + +**Mistake.** `node.localTransform.setTranslation(v)`, `node.localTransform..rotateY(t)`, +`node.position.x = 5`, or any edit of the matrix/vector a getter returns. This is the natural +`vector_math` style and the first thing most people reach for. + +**Symptom.** The node does not move. Not "moves wrong", nothing happens, forever, including its +children and bounds. Reading `node.localTransform`/`node.position` back shows the value you wrote, so +the state looks correct while the render disagrees. + +**Do instead.** Assign a fresh value (`node.position = ...`, `node.localTransform = node.localTransform.clone()..translateByVector3(v)`), +use the component setters `node.position`/`node.rotation`/`node.scale`, or edit the raw matrix +through `node.mutateLocalTransform((m) => m.translateByVector3(v))`, which dirties the cache for you. + +**[0.22.0 catches this]** Debug builds throw a `StateError` naming the node and the fix, both for an +in-place `localTransform` edit and for editing a copy returned by `position`/`rotation`/`scale`. + +--- + +## 2. Passing a normal or metallic-roughness map as `TextureContent.color` + +**Mistake.** `material.normalTexture = await Texture2D.fromAsset('brick_normal.png')` without +`content: TextureContent.normal`. The `content` parameter defaults to `color`. + +**Symptom.** The base mip is fine, so it looks right up close and progressively wrong with distance: +normals flatten and skew, roughness reads too smooth at range, specular shimmers. A +distance-dependent symptom is nearly the worst case for screenshot-driven iteration. + +**Do instead.** Build non-color maps with the right content: `Texture2D.fromAsset(path, content: +TextureContent.normal)` for normal maps, `TextureContent.data` for metallic-roughness, AO, and other +linear data. Still silent, so this is on you. + +--- + +## 3. Non-uniform scale on a lit mesh + +**Mistake.** Any non-uniform scale on the node or an ancestor, e.g. `node.scale = Vector3(1, 3, 1)`. + +**Symptom.** Lighting, specular, and reflections are wrong across the whole mesh. It reads as a +shading or material bug, so it sends you into the materials, never the transform. + +**Do instead.** Use a uniform scale, or bake the non-uniform scale into the geometry with +`MeshData.transformed(matrix)` and build a fresh `MeshGeometry` from it. Still silent. + +--- + +## 4. Moving a skinned mesh node + +**Mistake.** `skinnedNode.localTransform = Matrix4.translation(v)` on a node that carries a `Skin`. + +**Symptom.** The mesh does not move. Worse, it *does* move when the node you transformed happens to +be an ancestor of the skeleton's joints, so it looks intermittent across models. + +**Do instead.** glTF requires a skinned mesh node's own transform to be ignored, so the engine passes +identity. Move the skeleton root (the common ancestor of `skin.joints`) instead, or parent both the +mesh node and the skeleton under a shared node and move that. Still silent. + +--- + +## 5. Replacing the transform of a runtime-imported model root + +**Mistake.** +```dart +final model = await Node.fromGlbAsset('assets/ship.glb'); +model.localTransform = Matrix4.translation(v); // wipes the handedness flip +``` + +**Symptom.** The model renders mirrored through Z (asymmetric geometry reversed, text backwards) with +normals and IBL wrong for the mirrored orientation. It still draws and is not obviously broken. + +**Do instead.** The runtime glTF importer synthesizes a root carrying a `scale(1, 1, -1)` handedness +flip. Do not overwrite it. Parent that node under a new `Node` and transform the parent, or +pre-multiply your transform by the existing `localTransform`. (The offline `.fscene`/`loadScene` path +bakes handedness into the vertices, so its roots are identity and do not have this trap.) Still +silent. + +--- + +## 6. `EnvironmentMap.fromGpuTextures` with a raw panorama + +**Mistake.** +```dart +final tex = await gpuTextureFromAsset('assets/panorama.png'); +scene.environment = EnvironmentMap.fromGpuTextures(prefilteredRadiance: tex); +``` + +**Symptom.** Every reflective surface (and the sky) shows the top 1/8 of the panorama stretched 8x +vertically, cross-fading between slices as roughness varies. Diffuse is fully black. + +**Do instead.** `fromGpuTextures` expects an already-prefiltered radiance atlas, not a plain image. +Run the source through `prefilterEquirectRadiance()` first, or just use +`EnvironmentMap.fromEquirectImageAsset(assetPath: ...)`/`fromUIImages`, which prefilter and project +SH for you. Still silent. + +--- + +## 7. `ShaderMaterial(cullingMode: none)` for a double-sided custom material + +**Mistake.** `ShaderMaterial(cullingMode: gpu.CullMode.none)`, or setting `doubleSided = true` on a +`ShaderMaterial`. The two do not agree. + +**Symptom.** `cullingMode.none` draws back faces in color but leaves them out of the depth prepass, so +SSAO, SSR, and contact shadows sample the wrong surface exactly where a back face shows: dark halos, +wrong reflections, occlusion bleeding through a leaf card. `doubleSided = true` does the opposite: the +color pass still culls while the prepass draws the extra faces. + +**Do instead.** For a truly double-sided `ShaderMaterial`, understand that the color cull and the +prepass cull are driven separately today; keep the mesh single-sided where the depth-based effects +need to match, or split it. Still silent. + +--- + +## 8. Caller-supplied `bounds` that do not cover the geometry + +**Mistake.** `MeshGeometry.fromArrays(positions: p, bounds: someAabb)` where the AABB does not contain +every position, or `setLocalBounds` with a guessed or stale box. + +**Symptom.** The mesh pops out of existence at some camera angles and reappears at others; part of a +large mesh vanishes; shadows disappear before the caster does. Intermittent and view-dependent, so a +single screenshot can look fine. + +**Do instead.** Widen the bounds to cover every vertex, or just omit the `bounds` argument and let the +constructor scan the positions. An absent bounds is safe (it means always-visible); only a wrong one +is dangerous. Still silent. + +--- + +## 9. Binding a mipless texture to a material + +**Mistake.** `material.baseColorTexture = GpuTextureSource(await gpuTextureFromAsset('brick.png'))`. +The helper's own doc even recommends this. + +**Symptom.** Severe minification aliasing: crawling and shimmering on any surface at an angle or +distance, sparkling on a metallic-roughness map. `Texture2D.fromAsset` on the same file looks fine, so +two seemingly equivalent APIs disagree visually. + +**Do instead.** Use `Texture2D.fromAsset`/`fromImage`/`fromPixels`, which generate a mip chain, or +supply a texture you mipped yourself. Reserve `gpuTextureFromAsset` for non-material uses. Still +silent. + +--- + +## 10. `RenderView.layerMask`/`Node.layers` mismatch, or a zero mask + +**Mistake.** Putting a node on a non-default layer and forgetting the view side (or vice versa), or +`layerMask: 0`, or `Node.layers = 2` meaning to select "layer 2" (which is actually `1 << 2 == 4`). + +**Symptom.** A node, a group, or the whole scene is simply absent, with no hint a mask is involved. A +`layerMask` of 0 renders nothing. + +**Do instead.** `Node.layers` is a bitmask and is NOT inherited by children, so set it on each node +you want the view to see. Use `kRenderLayerAll` to see everything, or a bitmask like `(1 << 2)`. +Match the view's `layerMask` to the nodes' `layers`. Still silent (but see #23 for the +draws-nothing diagnostic). + +--- + +## 11. Mutating a `TextureTransform` in place + +**Mistake.** `material.baseColorTextureTransform.offset.x = 0.5` instead of assigning a fresh +`TextureTransform`. Same shape as trap #1, for materials. + +**Symptom.** UV scroll/rotation animation freezes at the first value, but ONLY for materials on the +physical-variant path (any material with clearcoat/sheen/transmission/etc). The identical code works +on a plain PBR material, so it reads as a shader bug. + +**Do instead.** Assign a new transform each frame: `material.baseColorTextureTransform = +TextureTransform(offset: ...)`. Still silent. + +--- + +## 12. Environment image that is not 2:1 equirectangular + +**Mistake.** Passing a cube cross, a 1:1 angular light probe, or a cropped panorama to any environment +entry point. HDRI downloads are not reliably 2:1. + +**Symptom.** The scene is lit from wildly wrong directions, reflections show mirrored or duplicated +content, the sky is smeared. + +**Do instead.** Re-project the source to a 2:1 latitude-longitude panorama before loading. Cube +crosses and angular probes are not supported. Still silent. + +--- + +## 13. Hand-built triangles wound clockwise + +**Mistake.** Generating triangles with clockwise winding instead of the standard Counter-Clockwise (CCW) +right-handed convention when feeding `MeshGeometry.fromArrays` or `GeometryBuilder`. + +**Symptom.** The mesh is invisible from outside and visible from inside; a closed shape looks hollow +or inside-out; lighting is inverted where it shows. ("See-through faces.") + +**Do instead.** flutter_scene's front faces wind COUNTER-CLOCKWISE (CCW) in model space, matching glTF +and standard 3D conventions. Ensure triangle indices wind CCW around the outward face normal, or omit +`normals` and let `GeometryBuilder` derive them from your winding. Still silent. + +--- + +## 14. Out-of-range indices in `fromArrays` + +**Mistake.** `MeshGeometry.fromArrays(positions: p /* 100 verts */, indices: [0, 1, 100])`, e.g. from +an off-by-one or an index list built against a different vertex array. + +**Symptom.** Stray triangles stretching to the origin or infinity, holes, flicker. On some backends +the fetch is clamped and on others it reads adjacent memory, so the symptom differs per backend. + +**Do instead.** Keep every index in `0 .. vertexCount - 1`. (`GeometryBuilder.addTriangle` range-checks +for you and throws; the `fromArrays` index path does not.) Still silent on the `fromArrays` path. + +--- + +## 15. A `vertexCount` that does not match the buffer in `setVertices` + +**Mistake.** `geometry.setVertices(bufferView, vertexCount)` where `vertexCount` is a byte count, a +float count, or a triangle count rather than a vertex count. + +**Symptom.** Too small: part of the mesh is missing. Too large: the draw reads past the buffer, giving +stray geometry or a dropped draw depending on backend. The buffer is fine, so the investigation goes +to the packing code. + +**Do instead.** `vertexCount` is a count of vertices. Prefer `uploadVertexData` (which validates the +stride, see #17) or `fromArrays` over the caller-managed `setVertices` path unless you really own the +GPU buffer. Still silent. + +--- + +## 16. Oversized texture on a low-end device + +**Mistake.** `EnvironmentMap.fromEquirectImageAsset(assetPath: 'pano_16k.hdr', maxWidth: 16384)` or +`EnvironmentMap.radianceCubeSize = 4096` on a device whose max texture size is lower. + +**Symptom.** A completely black environment: no IBL, no reflections, black sky. Works on the dev +machine, black on a phone. + +**Do instead.** Keep environment and texture sizes within the device limit; lower `maxWidth` or +`radianceCubeSize`. Test on the lowest-end target you support. Still silent. + +--- + +## 17. Hand-packing vertex bytes at the wrong stride + +**Mistake.** `SkinnedGeometry()..uploadVertexData(bytes, vertexCount, indices)` with the wrong stride +(a common one is 96 bytes having forgotten UV1, or the legacy 80-byte layout). + +**Symptom.** Washed-out colors, see-through faces, geometry smeared toward the origin. + +**Do instead.** Unskinned vertices are 72 bytes (position 3, normal 3, tex_coords 2, tex_coords_1 2, +color 4, tangent 4, all float32), skinned are 104 (+ joints 4, weights 4). Better, do not hand-pack: +use `MeshGeometry.fromArrays`, `fromMeshData`, or `GeometryBuilder`. + +**[0.22.0 catches this]** `uploadVertexData` on both `SkinnedGeometry` and `UnskinnedGeometry` now +throws an `ArgumentError` when the byte length does not match `vertexCount * stride`, naming the +expected layout. + +--- + +## 18. Custom attribute length not matching the vertex count + +**Mistake.** `geometry.setCustomAttribute('a_wind', data, components: 3)` where `data` has the wrong +length, or set before uploading vertices, or not re-set after a `rebuild` changed the count. + +**Symptom.** The attribute is read at the wrong stride, so every vertex gets a neighbor's value: a +displacement shader shears the mesh, a color attribute smears. Nearly right, so hard to spot. + +**Do instead.** `data.length` must equal `vertexCount * components`. Set the attribute after uploading +vertices, and re-set it after any rebuild. Also note custom attributes are not fetched by depth/shadow +passes, so an attribute-driven displacement will not show in shadows. + +**[0.22.0 catches this]** `setCustomAttribute` now throws an `ArgumentError` on a length mismatch +(once the vertex count is known). + +--- + +## 19. `UnlitMaterial` with `AlphaMode.mask` + +**Mistake.** `UnlitMaterial(colorTexture: foliage)..alphaMode = AlphaMode.mask` for cutout foliage. + +**Symptom.** No alpha test. Cutout edges render soft and blended, the material goes through the +translucent pass, writes no depth, sorts badly against itself, and casts no cutout shadow. + +**Do instead.** `UnlitMaterial` does not implement `mask` (it behaves as `blend`). Use +`PhysicallyBasedMaterial` for cutouts, or a `.fmat` unlit material that discards below your cutoff. +Still silent. + +--- + +## 20. `vertexColorWeight` on a material that took a physical variant + +**Mistake.** +```dart +final m = PhysicallyBasedMaterial()..vertexColorWeight = 0.0; +m.clearcoat = 1.0; // or sheen/transmission/anisotropy/ior != 1.5/any extension texture +``` + +**Symptom.** Vertex colors snap back to full strength the moment an unrelated extension is enabled. +On a vertex-colored import, an abrupt tint change with no plausible cause. (The same gap silently +drops `specularAntiAliasingVariance` and `specularAntiAliasingThreshold` on the variant path.) + +**Do instead.** Leave `vertexColorWeight` at 1.0 when using any advanced PBR feature, or drop the +extension. Still silent. + +--- + +## 21. Vertex-stage binding on a `ShaderMaterial` with no vertex shader + +**Mistake.** +```dart +final m = ShaderMaterial(fragmentShader: frag); +m.setUniformBlock('WaveInfo', bytes, stage: ShaderStage.vertex); // never set a vertex shader +``` + +**Symptom.** The vertex-stage parameter has no effect; geometry stays undisplaced while the fragment +stage looks right. Reads as "my vertex shader is not running." + +**Do instead.** Pass a `vertexShader` (and `skinnedVertexShader`/`depthVertexShader` for those mesh +kinds) to the constructor before binding vertex-stage blocks, or bind the block on +`ShaderStage.fragment`. Still silent. + +--- + +## 22. A `ShaderMaterial` vertex shader on line/trail/polyline geometry + +**Mistake.** Attaching a `ShaderMaterial` that supplies a vertex shader to a `LineSegmentsGeometry`, a +trail, or a polyline. + +**Symptom.** Lines vanish or explode into garbage. The unskinned vertex shader is paired with the +line-segments instanced layout and never does the ribbon expansion. + +**Do instead.** These geometries do their vertex expansion in the engine's own shader; a material +vertex shader cannot be used with them. Drop the vertex shader for line/trail/polyline geometry, or +use a mesh geometry. Still silent. + +--- + +## 23. Four different causes of a blank frame + +**Mistake.** Any of: a degenerate camera (target equals position, or `up` parallel to the view +direction, e.g. a top-down camera left at the default `up`), a field of view passed in degrees +(`fovRadiansY: 60`), an inverted or zero frustum, `layerMask: 0`, a zero-area draw region, or +rendering before `Scene.isReadyToRender`. + +**Symptom.** The entire scene is empty. Every one of these looks identical, so it is easy to "fix" +lighting, materials, and geometry for many iterations before suspecting the camera or the mask. + +**Do instead.** For a top-down/bottom-up camera set `up` to `Vector3(0, 0, 1)` or `Vector3(0, 0, +-1)`, not the default `(0, 1, 0)`. Pass FOV in radians (`60 * degrees2Radians`). Keep `near > 0` and +`far > near`. Give the view a non-zero `layerMask` and a non-empty draw region. + +**[0.22.0 catches most of this]** Degenerate cameras (zero view direction, parallel `up`, degrees-valued +FOV, degenerate near/far) assert in debug. And a frame that issues zero draw calls now prints once in +debug naming the likely cause (not ready, empty region, no views, no visible meshes, or a layer mask +matching nothing). + +--- + +## 24. Missing bounds after swapping a primitive's geometry + +**Mistake.** `mesh.primitives[0].geometry = newGeometry` for hand LOD, a rebuilt procedural mesh, or a +variant swap. + +**Symptom.** The new geometry is culled against the old geometry's bounds; if it is larger or +displaced, it pops in and out exactly like trap #8. + +**Do instead.** Nothing extra is needed anymore. + +**[0.22.0 catches this]** A `Mesh` now recomputes its bounds on its own when a primitive's geometry +identity changes, so the manual `markLocalBoundsDirty()` is no longer required. + +--- + +## 25. A `.fmat` material that overruns the 15-sampler budget + +**Mistake.** A `lit` or `physical` `.fmat` declaring several `sampler2d` parameters plus +`engine_inputs: [scene_color, scene_depth]`, on top of the lit framework's own textures. + +**Symptom.** Geometry disappears on a mid-range Android device while everything is correct on Metal, +with no build-time signal. The draw is rejected on GLES drivers reporting the 16-unit minimum. + +**Do instead.** The lit fragment shader budgets 15 fragment samplers. Pack channels into one texture +(an ORM-style atlas), drop an `engine_input`, or make the material unlit. Still silent (fails at +runtime on the device, not at build). + +--- + +## 26. `RenderView.viewport` with a `target` set + +**Mistake.** `RenderView(camera: cam, target: myRenderTexture, viewport: Rect.fromLTWH(0, 0, 0.5, 1))` +expecting a half-width render into the texture. + +**Symptom.** The view fills the entire render texture; the passed `viewport` is ignored. Reads as +"my viewport math is off." + +**Do instead.** `viewport` is ignored when `target` is set. Size the `RenderTexture` to the region you +want, or drop the target to render a sub-rect of the screen. Still silent. + +--- + +## 27. Scaled or mirrored camera node + +**Mistake.** Attaching a `CameraComponent` to a scaled node, or parenting a camera node under a scaled +one. + +**Symptom.** A uniform scale rescales the world in view; a negative scale mirrors the view, so every +surface goes back-facing and the scene renders inside out. The camera's reported `forward`/`up` look +correct, which makes it hard. + +**Do instead.** A camera node must carry only rotation and translation, and no ancestor may be scaled. +Still silent. + +--- + +## 28. Hand-built `Skin` with mismatched joints and inverse-bind matrices + +**Mistake.** `skin.joints.add(n)` without a matching `skin.inverseBindMatrices.add(...)` (both are +plain mutable lists). + +**Symptom.** Extra inverse bind matrices are silently ignored and the mesh deforms wrongly. (Too few +throws a `RangeError`, so only the extra-matrices direction is silent.) + +**Do instead.** Keep the two lists parallel: one inverse bind matrix per joint (`Matrix4.identity()` +if the joint's rest pose is the mesh's model space). Imported skins are validated; hand-built ones are +not. Still silent. + +--- + +## 29. Cloning a mesh node whose skeleton is a sibling + +**Mistake.** `meshNode.clone()` when the skeleton lives outside the cloned subtree. + +**Symptom.** The clone renders collapsed or in bind-pose garbage. There is a `debugPrint`, but it says +only "Index path formation failed" and names neither the skin nor the consequence. + +**Do instead.** Clone the common ancestor of the mesh node and its skeleton, not the mesh node alone. +Still effectively silent. + +--- + +## 30. `updateInstanceTransforms(recomputeWinding: false)` with a mirroring edit + +**Mistake.** Editing an instance transform to a negative determinant while asking the engine to skip +the parity refresh. + +**Symptom.** Those instances render inside out (front faces culled, back faces lit). + +**Do instead.** Drop `recomputeWinding: false`, or keep every instance edit orientation-preserving +(no negative/mirrored scale). Still silent. + +--- + +## 31. Flipbook frame count vs atlas grid mismatch + +**Mistake.** A `FlipbookModule(frameCount: 16)` without `emitter.flipbookColumns = 4; +emitter.flipbookRows = 4`. + +**Symptom.** Particles sample the wrong atlas cells, or only the first cell; the effect animates but +shows the wrong art. + +**Do instead.** Set `flipbookColumns * flipbookRows` equal to the module's `frameCount`. Still silent. + +--- + +## 32. `LodComponent` blend bands overlapping + +**Mistake.** A `blendRange` larger than the gap between adjacent LOD thresholds. + +**Symptom.** An object sits permanently in the wrong cross-fade pair, dither-blending two levels that +should not blend, or skipping a level. + +**Do instead.** Keep `blendRange` smaller than the smallest gap between adjacent `screenSize` +thresholds. Still silent. + +--- + +## 33. `TextureAtlas` grid not matching its texture + +**Mistake.** `TextureAtlas(columns: 16, rows: 16, tileSize: 32, padding: 2, baseColor: eightBySix)` +where the grid does not match the image, or an out-of-range tile `index`. + +**Symptom.** Every UV points at the wrong tile. With the default `repeat` addressing, an out-of-range +index in release wraps to a different valid-looking tile rather than failing. + +**Do instead.** Make the grid parameters produce exactly the texture's dimensions +(`columns * (tileSize + 2*padding)` etc), keep tile indices in range, and set +`TextureSampling.maxMipmapLevels` so tiles do not bleed across the padding gutter at high mips. Still +silent. + +--- + +## 34. `useEnvironment` sky with no cube-radiance variant + +**Mistake.** `ShaderSkySource(fragmentShader: myShader, useEnvironment: true)` with +`radianceCubeFragmentShader` left null. + +**Symptom.** The sky contributes no image-based specular on any backend that builds the cube layout +(the default nearly everywhere), so the scene loses its reflections. + +**Do instead.** Supply `radianceCubeFragmentShader`, the entry built with +`FLUTTER_SCENE_RADIANCE_CUBE`. Debug builds warn about this at bind; release builds are silent, so do +not rely on the warning. + +--- + +## 35. `radianceCubeFragmentShader` that is not the cube build + +**Mistake.** `ShaderMaterial(fragmentShader: f, radianceCubeFragmentShader: f)` (the same shader +twice), or naming the non-cube entry as the cube twin. + +**Symptom.** The engine binds a cubemap into a shader whose sampler is a `sampler2D`: nothing on some +backends, garbage specular on others. + +**Do instead.** The cube variant must be the entry compiled with `FLUTTER_SCENE_RADIANCE_CUBE`, whose +`prefiltered_radiance` sampler is a `samplerCube`. Pass the distinct `...Cube` entry from your bundle. +(The engine can only catch the identical-shader case, and only in debug.) Still effectively silent. + +--- + +## 36. Reading `int`/`bool`/`uint` shader members through `setUniformBlockFromFloats` + +**Mistake.** `setUniformBlockFromFloats('FragInfo', [1.0, 0.5])` where the shader declares `int mode; +float amount;`. + +**Symptom.** The shader reads `mode` as the float bit pattern of `1.0` (a huge integer), so every +`if (mode == 1)` branch misses and the material takes its fallback path. The block size is correct, so +nothing complains. + +**Do instead.** Pack integer members with `ByteData.setInt32` at the member's offset, or use a `.fmat` +material whose `MaterialParameters` type-checks every assignment. Unenforceable at runtime. + +--- + +## 37. A custom fragment shader that tone-maps or writes straight alpha + +**Mistake.** Ending a `ShaderMaterial`/`ShaderSkySource`/`beforeTonemap` `PostEffect` fragment +shader with `frag_color = vec4(color, alpha)` (straight alpha) or `pow(color, vec3(1.0/2.2))` +(gamma-encoded). + +**Symptom.** Straight alpha gives edge halos and over-bright overlaps. sRGB output is tone-mapped and +EOTF-encoded a second time by the resolve pass, giving washed-out low-contrast color that looks like a +bad exposure. + +**Do instead.** Output linear HDR premultiplied by alpha. Exposure, tone mapping, and the display +encode are applied later by the full-screen resolve pass. When sampling an sRGB texture, linearize +first. `.fmat` materials get the premultiply for free. Unenforceable at runtime. + +--- + +## 38. `MaterialParameters.copyStateFrom` across a changed layout + +**Mistake.** Applying a re-realized material onto a live instance whose shader layout changed (an +editor hot reload where the `.fmat` gained or lost a parameter). + +**Symptom.** Every parameter reverts to its sidecar default while `assignedValues` still reports your +overrides, so the inspector shows the right numbers and the render shows the wrong ones. + +**Do instead.** `copyStateFrom` needs both sides to come from the same compiled shader entry; remap by +name through `updateFromMetadata` across a layout change instead. Still silent. + +--- + +## 39. Environment or widget textures with sub-255 alpha + +**Mistake.** Passing an equirect image carrying alpha below 255 (an unfilled sky dome, a masked +panorama) to `fromUIImages`/`fromEquirectImageAsset`. Or, for `WidgetTexture`/`WidgetComponent`, +simply using a widget with anti-aliased or translucent edges. + +**Symptom.** For environments, diffuse ambient comes out darker than the specular reflections of the +same environment, so objects look lit by two environments. For widget textures, dark halos around +anti-aliased text and rounded corners on the zero-copy path (correct on the web readback path, so it +reads as a platform quirk). + +**Do instead.** Use opaque (alpha 255) environment sources. The widget-alpha double-multiply is a +backend difference you cannot fully control from the API; keep widget content opaque where you can. +Still silent. + +--- + +## Now caught by the engine, in one place + +For quick reference, these traps became loud in 0.22.0. If you hit one you get an error, not silent +wrong pixels: + +- In-place edit of `localTransform`/`position`/`rotation`/`scale` -> throws in debug (#1). +- Degenerate camera and a frame that draws nothing -> asserts/prints once in debug (#23). +- `uploadVertexData` and `setCustomAttribute` length mismatches -> throw always (#17, #18). +- A `Mesh` whose primitive geometry is swapped -> recomputes bounds itself (#24). +- An `AnimationClip` binding zero of its channels -> asserts in debug naming the wanted nodes. +- A web-backend bind to a shader uniform/texture name the shader does not declare -> throws (matches + native), instead of silently sampling whatever was bound last. +- Also fixed outright: `Node.clone()` sharing the original's matrix, the skinning joints texture being + too narrow for small joint counts, and `ParticleSystem.reset()` not restarting its random stream. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/what-exists.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/what-exists.md new file mode 100644 index 0000000..b610d0c --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/what-exists.md @@ -0,0 +1,474 @@ +# What exists in flutter_scene + +Complete public API inventory (package version 0.22.0). flutter_scene has lights, shadows, PBR +materials, instancing, LOD, skeletal animation, and a full post-processing stack. If you think a +feature is missing, it is almost certainly here under the name below. Look before you hand-roll. + +The public surface is the explicit `show` lists in `lib/scene.dart` (plus the separate barrels +`gpu.dart`, `fscene.dart`, `build_hooks.dart`, `physics.dart`, `audio.dart`). Nothing under +`lib/src` is public unless a barrel shows it. + +Import: +```dart +import 'package:flutter_scene/scene.dart'; +import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64 +``` + +--- + +## Node and scene graph + +`Node` (`base class Node implements SceneGraph`). Construct `Node({String name = '', Matrix4? +localTransform, Mesh? mesh})`. A non-null `mesh` is wrapped in a `MeshComponent`. + +Transform API (0.22.0 added the component properties; older docs say only `localTransform` exists): + +| Member | Type | Notes | +| --- | --- | --- | +| `position` | `Vector3` get/set | Getter returns a copy; editing the copy in place throws in debug. Assign to move. | +| `rotation` | `Quaternion` get/set | Same copy rule. | +| `scale` | `Vector3` get/set | Same copy rule. | +| `localTransform` | `Matrix4` get/set | Getter returns the LIVE matrix; in-place edit throws in debug on next read. Assign a fresh matrix. | +| `mutateLocalTransform(void Function(Matrix4) edit)` | method | Edits in place AND dirties the cache. Correct raw-matrix path. | +| `globalTransform` | `Matrix4` get/set | Cached world transform; setter solves for the needed local. | +| `lookAt(target, {up})` | method | Orients the node's forward axis (local +Z) at a world-space target; preserves world position and scale. | +| `lookAtFrom(eye, target, {up})` | method | Positions at `eye` and aims +Z at `target` in one call (the imperative camera one-liner). | +| `Node.lookAtTransform(eye, target, {up})` | static -> `Matrix4` | The `lookAt` basis as a local transform, for `Node(localTransform:)` and declarative `transform:`. | + ++Z is the forward axis engine-wide (cameras, directional/spot lights, imported models), so the +lookAt helpers aim any of them. Compose a plain matrix with `vm.Matrix4.translation(v)`, +`vm.Matrix4.rotationY(a)`, `vm.Matrix4.compose(t, q, s)`. There is no `translate`/`rotateX` on Node. + +Hierarchy (`SceneGraph` is a mixin): `add`, `addAll`, `addMesh`, `remove`, `removeAll`. `add` throws +if the child already has a parent. `parent`, `children`, `detach()`, `getRoot()`, `getDepth()`. + +Lookup: `getChildByName(name, {excludeAnimationPlayers})`, `getChildByNamePath`, +`getChildByIndexPath`, static `getNamePath`/`getIndexPath`, `meshNodes`, `clone({recursive = true})`. + +Per-node flags: `visible` (true), `frustumCulled` (true), `layers` (`kRenderLayerDefault`, a 32-bit +mask, NOT inherited), `castsShadows` (true, not inherited), `shadowStatic` (false), `raycastable` +(true), `highlightColor` (`Vector4?`), `skin` (`Skin?`, set by importers). + +Bounds: `combinedLocalBounds`, `combinedWorldBounds`, `markBoundsDirty()`, `isVisibleTo(camera, +size)`. A `null` bounds means always-visible. + +Loading models (see Assets): `Node.fromGlbAsset`, `Node.fromGlbBytes`, `Node.fromGltfBytes`. + +Geometry readback: `extractMeshData({Matrix4? transform})` flattens the subtree to one `MeshData`. +Throws on instanced meshes, non-triangle primitives, caller-managed geometry, or an empty subtree. + +`Scene` (`base class Scene implements SceneGraph`, cannot be subclassed). See the render/lighting/ +post sections. `Mesh(geometry, material)`/`Mesh.primitives({primitives})`; `MeshPrimitive(geometry, +material)`; `Mesh.clone()` (shallow, shares geometry+material); `Mesh.localBounds`, +`Mesh.markLocalBoundsDirty()`. + +### Camera + +- `PerspectiveCamera({double fovRadiansY = 45 * degrees2Radians, Vector3? position /*(0,0,-5)*/, + Vector3? target /*(0,0,0)*/, Vector3? up /*(0,1,0)*/, double fovNear = 0.1, double fovFar = + 1000.0})`. Field names are `fovNear`/`fovFar`, NOT `near`/`far`. +- `PerspectiveCamera.framing(Aabb3 bounds, {direction, fovRadiansY, up, margin = 1.1})`. +- `PerspectiveProjection({fovRadiansY, near = 0.1, far = 1000.0})` and abstract `CameraProjection`, + `Camera`. Camera helpers: `screenPointToRay`, `worldToScreen`, `getViewMatrix`, `getFrustum`. +- There is NO `OrthographicCamera`. Implement `CameraProjection`/`Camera` for other projections. +- Node-driven: `CameraComponent({CameraProjection? projection, activateOnMount = false})` -> + `toCamera()` gives a `NodeCamera`. Camera node must not be scaled. +- Interactive cameras: `CameraController` components attached to the camera node. `OrbitCameraController` + (turntable around `target`; `orbitBy`/`dollyBy`/`panBy`/`frame`), `FlyCameraController` (WASD + drag + free flight; `moveVertical: false` = grounded first-person; `look`), `FollowCameraController` + (third-person easing behind `followTarget` node; `orbitBy`/`dollyBy`). All ease with frame-rate + independent `smoothing` (settle seconds), clamp pitch short of vertical, and write the node via + `lookAtFrom`. Wire input with the `CameraControls({required controller, enabled, autofocus, child})` + widget (Focus + gestures + wheel); `SceneView` has no camera-input params by design. + +--- + +## Components + +`abstract class Component`. Lifecycle hooks (exact names): `onAttach`, `onLoad` (async), `onMount`, +`update(double deltaSeconds)` (NOT `onUpdate`), `fixedUpdate(double)`, `onUnmount`, `onDetach`, +`cloneFor(Node)`. Node side: `addComponent`, `removeComponent`, `getComponent()`, +`getComponents()`. + +| Component | Constructor/notes | +| --- | --- | +| `MeshComponent` | `MeshComponent(mesh)`; `mesh` get/set, `refreshMaterials()` | +| `InstancedMeshComponent` | `InstancedMeshComponent(instancedMesh)` | +| `LodComponent` | `LodComponent(List, {lodBias = 1.0, hysteresis = 0.1, blendRange = 0.0})`; extends MeshComponent | +| `CameraComponent`, `NodeCamera` | see Camera | +| `DirectionalLightComponent` | `(light)` aims down node local +Z; `.aimed(light, localDir)`; `.fromLightDirection(light)` | +| `PointLightComponent` | `(light)`; `worldPosition` | +| `SpotLightComponent` | `(light)`; `worldPosition`, `worldDirection` | +| `RectAreaLightComponent` | `(light)`; `worldPosition`, `worldRight`, `worldUp` | +| `EnvironmentVolumeComponent` | `({required settings, shape = box, extents, radius = 5.0, blendDistance = 1.0, priority = 0.0, weight = 1.0})` | +| `ReflectionProbeComponent` | `({extents = Vector3.all(5), blendDistance = 1.0, priority = 10.0, weight = 1.0, faceResolution = 128, captureOnActivate = true})`; parallax-corrected local reflections in the box; `requestCapture()` re-captures | +| `MaterialsVariantsComponent` | No public ctor. `MaterialsVariantsComponent.of(root)`/`.allOf(root)`, then `select(name)`, `variants`, `selected` | +| `SemanticsComponent` | `({label, value, hint, button, onTap, ... boundsOverride, properties})` | +| `WidgetComponent` | `({required Widget child, required Size size, pixelRatio = 1.0, worldHeight = 1.0, update = everyFrame, input = automatic, ...})`; `.bindOnly(...)` | +| `SplatComponent` | `SplatComponent(GaussianSplats)`; `opacity`, `splatScale`, `tint`, `shDegree`, `cropBox`, `cropMode` | +| `ParticleEmitterComponent` | `({required system, SpriteMaterial? material})`; `facing`, `flipbookColumns/Rows/Blend`, `paused` | +| `MeshParticleEmitterComponent` | `({required system, required List geometries, required material, facing = tumble})` | +| `TrailComponent` | `({width = 0.25, lifetime = 0.6, minVertexDistance = 0.05, maxPoints = 48, ...})`; `emitting`, `clear()` | + +`SemanticsComponent`, `SplatComponent`, particle emitters, `TrailComponent`, `WidgetInput`, +`MeshParticleFacing`, `LodLevel`, `EnvironmentVolumeShape` are all exported. + +--- + +## Geometry + +### Primitives (`primitives.dart`, all factory constructors, all `extends MeshGeometry`) + +| Class | Constructor | Facing/notes | +| --- | --- | --- | +| `CuboidGeometry` | `CuboidGeometry(Vector3 extents, {debugColors = false})` | positional extents; box `-extents/2..+extents/2`; debugColors off | +| `WedgeGeometry` | `WedgeGeometry(Vector3 size)` | triangular prism; base on `y=0`, not Y-centered | +| `PlaneGeometry` | `({width = 1.0, depth = 1.0, segmentsX = 1, segmentsZ = 1})` | XZ plane, faces +Y; no collisionShape | +| `SphereGeometry` | `({radius = 0.5, segments = 32, rings = 16})` | UV sphere | +| `CylinderGeometry` | `({bottomRadius = 0.5, topRadius = 0.5, height = 1.0, radialSegments = 32, heightSegments = 1, bottomCap = true, topCap = true})` | topRadius 0 = cone | +| `CapsuleGeometry` | `({radius = 0.5, height = 1.0, radialSegments = 32, capRings = 8})` | `height` is the mid-section; total Y = height + 2*radius | +| `TorusGeometry` | `({radius = 0.5, tubeRadius = 0.2, radialSegments = 32, tubularSegments = 16})` | XZ plane | +| `DiscGeometry` | `({radius = 0.5, segments = 32})` | XZ, faces +Y | +| `RingGeometry` | `({innerRadius = 0.25, outerRadius = 0.5, segments = 32})` | annulus, XZ, +Y | +| `IcosphereGeometry` | `({radius = 0.5, subdivisions = 2})` | subdivided icosahedron | + +Every primitive except `PlaneGeometry` has a `Shape get collisionShape`. + +### Swept/procedural (sweep a `ScenePath`; also `BezierPath`, `CatmullRomPath`, `PolylinePath`) + +- `RibbonGeometry(path, {width = 1.0, stations = 64, alignment = RibbonAlignment.ground, up, storage = fixed})`; `updatePath(path)`. `RibbonAlignment` = `ground` | `path`. +- `TubeGeometry(path, {radius = 0.5, radialSegments = 12, stations = 64, caps = true, storage})`. +- `ExtrudeGeometry(path, {required List profile, stations = 64, caps = true, storage})`. +- `PolylineGeometry(List points, {width = 8.0, widthMode = screenPixels, cap = butt, dash, perVertexWidth, perVertexColor})`. INERT until `updateForCamera(camera, viewportSize)` is called every frame. `PolylineWidthMode` = `screenPixels` | `worldUnits`; `PolylineCap` = `butt` | `round`; `DashPattern({dashLength, gapLength, cap})`. +- `LineSegmentsGeometry(LineSegmentData segments, {width = 0.01, normalOffset = 0.0})`. `extends Geometry`, GPU-expanded, no per-frame CPU work. For large independent-segment sets. +- `BillboardGeometry({capacity = 256})`. `floatsPerInstance = 14`; `BillboardFacing` = `spherical` | `axisLocked` | `velocityStretched`. + +### MeshGeometry, GeometryBuilder, MeshData + +`MeshGeometry.fromArrays({required Float32List positions, Float32List? normals, texCoords, +texCoords1, colors, tangents, List? indices, primitiveType = triangle, Aabb3? bounds, storage = +fixed, GeometryBufferArena? bufferArena, retainCpuData = true})`. Components per vertex: positions 3, +normals 3, texCoords/texCoords1 2, colors/tangents 4. Omitted normals on a triangle list are +generated. Omitted indices need a vertex count divisible by 3. `bounds` skips the position scan (it +must actually cover every vertex). + +`MeshGeometry.fromMeshData(MeshData data, {storage, bufferArena, retainCpuData})`. + +In-place update (require `GeometryStorage.updatable`, all take `{dirtyStart, dirtyCount}`): +`updatePositions`, `updateNormals`, `updateTexCoords`, `updateTexCoords1`, `updateColors`, +`updateTangents`. `rebuild({positions, normals, ...})` may change the vertex/index count. +`applyMeshData(data)`. `GeometryStorage` = `fixed` | `updatable`. + +`GeometryBuilder({deduplicate = true})`: `normal(v)`, `texCoord(v)`, `texCoord1(v)`, `color(v)`, +`tangent(v)`, `addVertex(Vector3) -> int`, `addTriangle(a, b, c)` (throws RangeError on bad index), +`packVertices()`, `build({storage, bufferArena, retainCpuData})`. Attribute setters are STICKY. +Calling `normal()` once disables generated normals for the whole mesh. + +`MeshData` (isolate-transferable, pure): `MeshData({required positions, required vertexCount, +normals, ..., customAttributes})`, `MeshData.build({required positions, ...})` (derives vertexCount, +generates normals). Derivations: `triangleCount`, `triangles`, `transformed(Matrix4)` (inverse +transpose for normals; a mirror flips winding), `toTriMeshShape()` (hollow static collider), +`toConvexHullShape()` (dynamic body), `unweld({attributes})`, `extractEdges({creaseAngleDegrees})`, +static `merge(parts)`. `MeshAttributeData(data, {components})`; `UnweldAttribute` = `centroid` | +`seed` | `triangleIndex` | `barycentric`; `LineSegmentData({positions, normals})`. + +`Geometry` base: `primitiveType`, `localBounds`, `localBoundingSphere`, `setLocalBounds(aabb, +sphere)`, `setVertices(BufferView, vertexCount)`, `setIndices(BufferView, indexType)`, +`setCustomAttribute(name, Float32List, {required components})` (1..4; not fetched by depth passes so +it does not affect shadows), `uploadVertexData(ByteData, vertexCount, ByteData? indices, {indexType = +int16})`, `isReadable`, `extractMeshData()`, `setVertexShader`/`setVertexShaderName`, +`setVertexLayout(descriptor, {bindsModelTransform = true})`, `draw(pass, {instanceCount = 1})`. +`SkinnedGeometry`/`UnskinnedGeometry` subclasses. `GeometryBufferArena({blockSizeInBytes = 16MB})`. + +Vertex layout: unskinned 72 bytes/18 floats = position(3) normal(3) texture_coords(2) +texture_coords_1(2) color(4) tangent(4). Skinned 104 bytes/26 floats = + joints(4) weights(4). Do +not hand-pack; use `fromArrays`/`fromMeshData`/`GeometryBuilder`. + +### Instancing and LOD + +`InstancedMesh({required geometry, required material, cullInstances = false, +sortTransparentInstances = true})`: `instanceCount`, `addInstance(Matrix4, {Vector4? color}) -> int` +(clones the matrix), `setInstanceTransform(i, m)`, `updateInstanceTransforms(update, +{recomputeWinding = true})`, `setInstanceColor(i, color)`, `removeInstanceAt(i)`, `clearInstances()`. +Attach via `InstancedMeshComponent`. + +`LodLevel({required geometry, required material, required double screenSize})` (screenSize = projected +bounding-sphere diameter as a fraction of viewport height, descending, last is the cull floor). +Attach via `LodComponent`. Shadow/depth passes always draw level 0. + +--- + +## Materials and textures + +`Material` (abstract): `name`, `doubleSided` (false), `depthBias` (0.0), `setFragmentShader`, +`setFragmentShaderName(name, {cubeName})`, `setRadianceCubeFragmentShader`, `isOpaque()`. + +### UnlitMaterial + +`UnlitMaterial({TextureSource? colorTexture})`. `baseColorTexture` (field name differs from the ctor +arg), `baseColorTextureTransform`, `baseColorTextureTexCoord` (0), `alphaMode` (`opaque`; `mask` not +implemented, behaves as blend), `baseColorFactor` (white), `vertexColorWeight` (1.0). Fog applies. + +### PhysicallyBasedMaterial + +`PhysicallyBasedMaterial({baseColorTexture, metallicRoughnessTexture, normalTexture, emissiveTexture, +occlusionTexture, EnvironmentMap? environment})`. Every texture slot is a `TextureSource?` with a +`TextureTransform` and `TextureTexCoord`. + +Core: `baseColorFactor` (white), `vertexColorWeight` (1.0), `metallicFactor` (1.0), `roughnessFactor` +(1.0), `normalScale` (1.0), `emissiveFactor` (`Vector4.zero()`), `emissiveStrength` (1.0), +`occlusionStrength` (1.0), `environment` (null, falls back to `Scene.environment`), `alphaMode` +(opaque), `alphaCutoff` (0.5), `specularAntiAliasingVariance` (0.15), `specularAntiAliasingThreshold` +(0.2). + +Advanced KHR_materials_* (setting any flips onto an internal physical-variant shader; each has a +`Texture`): `specular` (1.0), `specularColor`, `ior` (1.5), `clearcoat` (0.0), +`clearcoatRoughness` (0.0), `clearcoatNormalScale`, `sheenColor` (zero), `sheenRoughness` (0.0), +`transmission` (0.0), `diffuseTransmission`, `diffuseTransmissionColor`, `thickness` (0.0), +`attenuationDistance` (inf), `attenuationColor`, `dispersion` (0.0), `iridescence` (0.0), +`iridescenceIor` (1.3), `iridescenceThicknessMinimum` (100.0), `iridescenceThicknessMaximum` (400.0), +`anisotropy` (0.0), `anisotropyRotation` (0.0). + +`isOpaque()` is false when `transmission > 0`, `alphaMode == blend`, or `baseColorFactor.a < 1.0`. + +`AlphaMode` = `opaque` | `mask` | `blend`. `TextureTransform({offset, scale, rotation})` (glTF +KHR_texture_transform order). + +### SpriteMaterial + +`SpriteMaterial({TextureSource? colorTexture})`: `colorTexture`, `tint` (white), `blendMode` +(`SpriteBlendMode.alpha` | `additive`), `softDepthFade` (0.0), `cameraNearFade` (0.0), `sampler`. +Always non-opaque, always cull none. + +### ShaderMaterial (raw GLSL escape hatch) + +`ShaderMaterial({gpu.Shader? fragmentShader, radianceCubeFragmentShader, vertexShader, +skinnedVertexShader, depthVertexShader, useEnvironment = false, cullingMode = backFace, windingOrder = +counterClockwise, isOpaqueOverride = true})`. `setVertexShader(shader, {variant = unskinned})`, +`vertexShaderFor(variant)`, `setUniformBlock(name, ByteData?, {stage = fragment})`, +`setUniformBlockFromFloats(name, List, {stage})`, `getUniformBlock`, `uniformBlockNames`, +`setTexture(name, texture, {sampler, stage})` (accepts `gpu.Texture`/`Texture2D`/`RenderTexture`), +`getTexture`, `textureNames`. `ShaderStage` = `vertex` | `fragment`; `MeshVariant` = `unskinned` | +`skinned` | `depth`. + +Fragment shaders MUST output linear HDR premultiplied by alpha (exposure, tone mapping, and the +display encode are applied later by the resolve pass). Same contract for `ShaderSkySource` and +`PostInsertion.beforeTonemap` effects. std140 packing is by hand. + +### .fmat (declarative, recommended over ShaderMaterial) + +`loadFmatMaterial(sourcePath) -> PreprocessedMaterial`, `loadFmatSky(...) -> PreprocessedSky`. +`PreprocessedMaterial`: `parameters` (`MaterialParameters`), `shadingModel`, `environment`. +`MaterialParameters` (typed, reflection-backed, throws on wrong type/name): `setFloat`, `setInt`, +`setVec2/3/4`, `setMat4`, `setColor(name, Color)`, `setTexture(name, gpu.Texture, {sampler})`, +`operator []=`, `parameterNames`, `samplerNames`, `hasUniformBlock`. + +### Textures + +`TextureSource` (interface): implementers are `Texture2D`, `RenderTexture`, `GpuTextureSource`. Every +built-in material slot takes a `TextureSource`, not a raw `gpu.Texture`. + +`Texture2D` (factories, generates a mip chain): `Texture2D.fromPixels(Uint8List, w, h, {content = +color, sampling})`, `fromImage(ui.Image, {...})`, `fromAsset(String, {content = color, sampling, +bundle})`. `TextureContent` = `color` (sRGB) | `data` (linear, e.g. metallic-roughness/AO) | `normal` +(vector-averaged). `TextureSampling({mipmaps = true, maxMipmapLevels, minFilter = linear, magFilter = +linear, mipFilter = linear, maxAnisotropy = 8, addressMode = repeat})`. + +`GpuTextureSource(gpu.Texture, {sampler})` adapts a raw texture. Barrel helpers: +`gpuTextureFromImage`, `gpuTextureFromAsset` (mipless, aliases on materials), `imageFromAsset`, +`imageFromBytes`. Cooked `.fstex`: `loadTexture(sourcePath, {package, bundle, sampling}) -> +TextureSource`, `releaseTexture`, `clearTextureCache`. + +Custom-shader GPU barrel (`package:flutter_scene/gpu.dart`): `Shader`, `ShaderLibrary`, +`loadShaderLibraryAsync` (use this, not `ShaderLibrary.fromAsset` which throws on web), +`resolveShaderBundleKey`, `Texture`, `SamplerOptions`, `MinMagFilter`, `MipFilter`, +`SamplerAddressMode`, `IndexType`, `VertexFormat`, `VertexStepMode`. + +--- + +## Lighting and environment + +Lights (all in `light.dart`, all fields mutable): + +- `DirectionalLight({direction /*(-0.3,-1,-0.2)*/, color, intensity = 3.0, priority = 0, castsShadow + = false, cacheStaticShadows = true, shadowFadeRange = 2.0, shadowSoftness = 0.08, shadowCascadeCount + = 4, shadowMaxDistance = 150.0, shadowCascadeSplitLambda = 0.6, shadowMapResolution = 1024, + shadowDepthBias = 0.02, shadowNormalBias = 0.02, shadowAmbientStrength = 0.0, shadowFilter = + rotatedPoisson, shadowCasterFaces = front, contactShadows = false, contactShadowDistance = 0.3, + angularRadius = 0.005})`. +- `PointLight({color, intensity = 1.0, range = 0.0, falloffExponent = 2.0})`. No shadows. +- `SpotLight({color, intensity = 1.0, range = 0.0, falloffExponent = 2.0, direction /*(0,-1,0)*/, + innerConeAngle = 0.0, outerConeAngle = pi/4, castsShadow = false, ...})`. +- `RectAreaLight({color, intensity = 1.0, width = 1.0, height = 1.0, range = 0.0})`. Local XY plane, + emits along +Z, no shadows. +- `SunLight(SunSky source, {castsShadow = true, ...})` drives `Scene.directionalLight` from a sky. + +`ShadowCasterFaces` = `front` | `back` | `both`. `DirectionalShadowFilter` = `rotatedPoisson` | +`fixedPcf` | `pcss`. `ShadowCascade`, `Lighting` (per-draw state) are exported. + +Scene lighting: `Scene.directionalLight` (`DirectionalLight?`, null = IBL only; honors `direction`; +highest-priority one gets cascaded shadows), `Scene.sunLight`, `Scene.environment` (`EnvironmentMap?`, +null falls back to `EnvironmentMap.studio()`; for genuinely no IBL use `EnvironmentMap.empty()`), +`Scene.environmentIntensity` (1.0), `Scene.environmentTransform` (`Matrix3.identity()`), +`Scene.skybox` (`Skybox?`, null = transparent), `Scene.skyEnvironment`. + +### EnvironmentMap + +Carries a prefiltered specular radiance atlas AND SH-9 diffuse coefficients (one texture path, no +separate radiance/irradiance). Factories: `.empty()`, `.constantDiffuse(ambientRadiance)`, +`.fromGpuTextures({required prefilteredRadiance, diffuseSphericalHarmonics, diffuseShTexture})` (the +texture must already be prefiltered), `.fromUIImages({required radianceImage, ...})`, +`.fromEquirectHdr({required Float32List linearPixels, w, h, ...})`, +`.fromEquirectImageAsset({required assetPath, maxWidth = 4096, ...})` (auto-detects .hdr/.exr/LDR), +`.fromEquirectImageBytes(...)`, `.fromSky(SkySource, {...})`, `.studio()` (zero-config default). +Deprecated: `.fromAssets` (use `.fromEquirectImageAsset`). Env images must be equirect 2:1. +`prefilterEquirectRadiance` is exported. `Scene.loadEnvironment(assetPath, {showSkybox = true, +skyBlur = 0.0, intensity, exposure, rotationY, maxWidth = 4096, bundle})` is one-call setup. + +### Skybox/sky sources + +`Skybox(SkySource source, {intensity = 1.0})`. `SkySource` implementers: `EnvironmentSkySource({blurriness += 0.0})`, `ShaderSkySource({fragmentShader, fragmentShaderName, radianceCubeFragmentShader, +useEnvironment = false})`, `GradientSkySource({zenithColor, horizonColor, groundColor, sunDirection, +sunColor, sunSharpness = 400.0})`, `PhysicalSkySource({sunDirection, sunAngularRadius = 0.0175, +rayleighCoefficient = 2.0, mieCoefficient = 0.005, turbidity = 10.0, energy = 1.0, ...})`. +`SkyEnvironment(ShaderSkySource, {refresh = manual, interval, faceResolution = 128, equirectWidth = +512})`; `SkyEnvironmentRefresh` = `manual` | `interval` | `everyFrame`. + +### Exposure and tone mapping + +`Scene.exposure` (1.0; not 2.0), `Scene.toneMapping` (`ToneMappingMode.pbrNeutral`; also `aces`, +`reinhard`, `linear`, `agx`), `Scene.agxWhite` (16.29), `Scene.agxContrast` (1.25). Static +`Scene.physicalCameraExposure({required aperture, shutterSpeed, iso})` returns a multiplier to assign +to `exposure`. + +--- + +## Post-processing + +Every effect is a settings object on `Scene`, off by default, turned on with `enabled`. Environment +looks blend via `EnvironmentSettings` (snapshot/lerp of the whole look) and `EnvironmentVolume` / +`EnvironmentVolumeComponent` (spatial). + +| Scene field | Type | Key fields (default) | Requires | +| --- | --- | --- | --- | +| `ambientOcclusion` | `AmbientOcclusionSettings` | `method` (obscurance/`groundTruth`), `radius` (0.33), `intensity` (1.0), `power` (1.5), `bentNormals` (false), `halfResolution` (true), `indirectLight` (0.0 = SSGI), `specularMode` | perspective camera | +| `screenSpaceReflections` | `ScreenSpaceReflectionsSettings` | `intensity` (1.0), `maxDistance` (24.4), `thickness` (0.46), `stride` (9.0), `maxSteps` (90), `blur` (0.3), `debugView` | perspective camera | +| `fog` | `Fog` | `mode` (`FogMode.exponential`; also none/linear/exponentialSquared), `color`, `density` (0.02), `start`/`end`, needs both `enabled` AND non-none `mode` | any camera | +| `godRays` | `GodRaysSettings` | `intensity` (1.0), `density` (0.5), `anisotropy` (0.7), `stepCount` (24), `maxDistance` (200), `color` | shadow-casting DirectionalLight + perspective camera | +| `depthOfField` | `DepthOfField` | `focusDistance` (10.0), `fStop` (2.8), `focalLength`, `sensorHeight` (0.024), `bladeCount`, `quality` (low/medium/high) | perspective camera | +| `autoExposure` | `AutoExposureSettings` | `strength` (0.55), `compensation`, `minEv` (-4), `maxEv` (4), `speedUp` (3.0), `speedDown` (1.0); multiplies on top of `exposure` | none | +| `postProcess` | `PostProcessSettings` | see below | none | + +`AmbientOcclusionMethod` = `obscurance` (McGuire SAO) | `groundTruth` (GTAO). `SpecularAmbientOcclusionMode` += `none` | `simple` | `bentCone`. `SsrDebugView` = composite/reflectedUv/hitMask/normal/confidence/depth. + +`PostProcessSettings` (all sub-settings off by default; mutate the nested objects): +- `colorGrading` (`ColorGradingSettings`): `brightness` (1.0), `contrast` (1.0), `saturation` (1.0), + `temperature`, `tint`, `lift`/`gamma`/`gain`, `lut` (`ColorLut?`, applies after tone mapping, + independent of `enabled`), `lutBlend` (1.0). +- `chromaticAberration` (`intensity` 0.2), `vignette` (`intensity` 0.5, `radius` 0.75, `smoothness` + 0.5), `filmGrain` (`intensity` 0.3), `bloom` (`threshold` 1.0, `intensity` 0.15, `scatter` 0.7, + and `lensFlare`: `enabled` false, `intensity` 1.0, `ghostCount` 4, `ghostSpacing` 0.3, `haloRadius` + 0.35, `haloIntensity` 1.0, `chromaticAberration` 0.005; rides the bloom, needs bloom enabled). +- `customEffects` (`List`). + +`ColorLut.fromCubeString`/`.fromCubeAsset` (Adobe `.cube`, edge 2..64). + +Custom post: `PostEffect({gpu.Shader? fragmentShader, insertion = beforeTonemap, enabled = true, +useFrameInfo = false})`, added to `scene.postProcess.customEffects`. Engine binds `uniform sampler2D +input_color` at `in vec2 v_uv`. `PostInsertion` = `beforeTonemap` (linear HDR premultiplied) | +`afterTonemap` (display-referred). + +--- + +## Scene, render, and widgets + +`Scene()` (no args; calls `initializeStaticResources()`, needs a live Flutter GPU context). Methods: +`add`, `addAll`, `addMesh`, `remove`, `removeAll`, `update(dt)` (optional), `render(camera, canvas, +{viewport, pixelRatio})`, `renderViews(views, canvas, {region, pixelRatio})`, `warmUp(views, +{includeOffscreen})`, `raycast(ray, {maxDistance, layerMask, where, includeInvisible})`, +`raycastAll(...)`, `addRenderPass`/`removeRenderPass`, `captureRenderGraph({viewIndex, request, +timeout})`, `captureEnvironment({required position, faceResolution = 128, equirectWidth = 512, +layerMask})` -> `EnvironmentMap` (one-shot static capture; use `ReflectionProbeComponent` for a +node-anchored, parallax-corrected, auto-blended probe). Statics: `Scene.initializeStaticResources()`, +`Scene.isReadyToRender`, `Scene.physicalCameraExposure`, `Scene.isAntiAliasingModeSupported`, +`Scene.effectiveAntiAliasingMode`. + +`Scene.antiAliasingMode` (`AntiAliasingMode.auto` -> msaa or fxaa; also `none`, `msaa`, `fxaa`, `smaa`), +`Scene.renderScale` (1.0), `Scene.filterQuality` (`FilterQuality.medium`), `Scene.views` +(`List` for RenderTexture targets). + +`RenderView({required Camera camera, RenderTexture? target, Rect? viewport /*normalized 0..1, +ignored when target set*/, int layerMask = kRenderLayerAll, order = 0, AntiAliasingMode? +antiAliasingMode, double? renderScale, FilterQuality? filterQuality, List cullingPlanes})`. +`kRenderLayerDefault = 1`, `kRenderLayerAll = 0xFFFFFFFF`. + +`RenderTexture`, `RenderTextureSampling`, `RenderTextureUpdate`, `RenderTextureView(renderTexture, +{fit = contain, filterQuality = medium, followLayout = false})`. + +Widgets: +- `SceneView(Scene scene, {Camera? camera, SceneCameraBuilder? cameraBuilder, SceneViewsBuilder? + viewsBuilder, autoTick = true, pixelRatio, onTick, loading, loadingBuilder, revealMinDuration, + warmUp = false, children})`. App-owned scene; does not write scene properties. `camera`, + `cameraBuilder`, `viewsBuilder` are mutually exclusive. +- `SceneView.declarative({environment, environmentIntensity = 1.0, exposure = 1.0, toneMapping = + pbrNeutral, camera, cameraBuilder, viewsBuilder, children, ...})`. View-owned scene. +- `SceneViewsBuilder` is exported as of 0.22.0 (older docs list it as a trap; it is public now). +- Declarative widgets: `SceneNode`, `SceneMesh`, `SceneModel`, `SceneSubtree`, `SceneNodeHost`, + `SceneNodeController`, `SceneModelSource`, `AssetModelSource`, `MemoryModelSource`, + `SceneAnimationSpec`. `WidgetTexture`, `WidgetTextureController`, `WidgetUpdatePolicy`. `SceneScope`. +- Camera resolution precedence: `camera` -> `cameraBuilder(elapsed)` -> `scene.camera` (or first + mounted `CameraComponent`) -> default `PerspectiveCamera()`. + +`CustomRenderPass`, `RenderInput`, `RenderPassContext`, `RenderStage`, `TransientWriter`, +`NodeFilter`, `HighlightStyle`, render-graph capture types (`CapturedPass`, `CapturedResource`, +`RenderGraphCaptureRequest`, `RenderGraphCaptureResult`) are all exported. + +--- + +## Assets and animation + +Setup: `flutter pub add flutter_scene` then `dart run flutter_scene:init`. Enable Flutter GPU with +`flutter run --enable-flutter-gpu` (native only; nothing for web). Requires Flutter 3.47 stable+, NOT +master. Impeller is default; do not pass `--enable-impeller`. Never pass +`--enable-experiment=native-assets` (breaks the build on Dart 3.10+). + +Two model-loading paths, do not conflate: + +- Pipeline (preferred, needs the `buildScenes` hook): `loadScene(sourcePath, {package, bundle, + registry, onReload, applyStageTo}) -> Future`. `sourcePath` is the SOURCE path relative to + the package root (e.g. `'assets/level.glb'`), NOT a generated name. Companions: + `loadSceneSubtree`, `releaseScene`, `clearSceneTemplateCache`. +- Runtime glTF (no hook, parses every load): `Node.fromGlbAsset(assetPath)`, + `Node.fromGlbBytes(bytes)`, `Node.fromGltfBytes(gltfJson, {required resolveUri})`. Each synthesizes + a root node. + +Sibling loaders: `loadTexture` (`.fstex`), `loadFmatMaterial`/`loadFmatSky` (`.fmat`). + +Build hooks (`package:flutter_scene/build_hooks.dart`): `buildScenes({buildInput, buildOutput, +inputFilePaths, discoveryRoot = 'assets/', assetMode = generatedTree, compressTextures = false})`, +`buildMaterials({...})`, `buildTextures({..., required textures, contents})`, `buildEngineAssets`, +`buildTargetShaderBundleJson`. Outputs land in `flutter_scene_generated/` (never commit +`.fsceneb`/`.shaderbundle`/`.fmat.json`/`.fstex`). Removed 0.21.0: `legacyOnly`, +`dataAssetsIfAvailable`, `outputDirectory`. + +Animation (`Animation`, `AnimationClip`, `AnimationPlayer` exported): + +- Off a loaded model: `node.parsedAnimations`, `node.findAnimationByName(name)`, + `node.createAnimationClip(animation)`, `node.removeAnimationClip(clip)`. Clips start paused at t=0; + call `play()`. +- `AnimationClip`: `playbackTime` (assignment is seek), `playbackTimeScale` (1; negative reverses), + `weight` (0..1), `playing`, `loop`. `play()`, `pause()`, `stop()`, `replay()`, `gotoAndPlay(t)`, + `seek(t)`, `advance(dt)`, `rebind(newTarget, {animation})`. Channels bind by node NAME; channels + whose node is absent from the subtree are dropped (0.22.0 asserts in debug when ALL channels drop). +- `AnimationPlayer`: `createAnimationClip(animation, bindTarget)` (a second call with the same + `Animation.name` replaces), `getClipByName`, `rebind`, `update(dt)` (auto-driven per frame). +- `Animation({name, channels})`, `AnimationChannel`, `BindKey({required nodeName, property = + translation})`, `AnimationProperty` = `translation` | `rotation` | `scale`. +- Declarative: `SceneModel(assetPath, animations: [SceneAnimationSpec(name, {playing = true, loop = + true, weight = 1.0, speed = 1.0})])`. Note `SceneModel` loads via the runtime glTF path. + +The engine-agnostic scene-document core is a separate package `scene` (0.2.0), re-exported through +`package:flutter_scene/fscene.dart`. `flutter_scene_importer` and `flutter_gpu_shim` no longer exist +(folded in). Physics and audio are separate barrels (`physics.dart`, `audio.dart`). diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md new file mode 100644 index 0000000..7ec5d93 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md @@ -0,0 +1,146 @@ +--- +name: flutter_scene-kit +version: 3 +description: Build interactive 3D gameplay, character controllers, camera rigs, dynamic day/night cycles, water surfaces, audio, pooling, and debug overlays in flutter_scene. Use when creating game mechanics, camera controls, NPC behaviors, atmospheric environments, or diagnostic HUDs. +--- + +# Gameplay, camera, and atmosphere kit in flutter_scene + +Flutter Scene provides high-level gameplay components and ergonomic building blocks in `package:flutter_scene/kit.dart` so games and interactive experiences do not need to re-implement standard mechanics from scratch. + +When choosing components, consider existing engine alternatives: +- For physics-driven character navigation with collider capsules, wall sliding, and autostep, use `KinematicCharacterController` from `package:flutter_scene/physics.dart`. +- For interactive mouse/touch orbit cameras with inertia, use `OrbitCameraController` or `FollowCameraController`. +- For framing a standalone `PerspectiveCamera`, use `PerspectiveCamera.framing`. Use `BoundsFraming` when computing a transform for a `NodeCamera` mounted in the scene graph. + +## Imports + +```dart +import 'package:flutter_scene/scene.dart'; +import 'package:flutter_scene/kit.dart'; +import 'package:vector_math/vector_math.dart' as vm; +``` + +## Camera rigs and smoothing + +### SpringArmComponent + +`SpringArmComponent` attaches to a target character node and mounts a camera node at the arm's socket. It casts rays against the scene hierarchy to prevent geometry clipping, smoothly pulling the camera inward when colliding with walls. + +Note on offsets: `targetOffset` is applied in world space from the character node's origin, and `socketOffset` acts in the camera socket's local plane along X (right) and Y (up). + +```dart +final characterNode = Node(); +final cameraNode = Node(); +final cameraArm = SpringArmComponent( + targetLength: 5.0, + targetOffset: vm.Vector3(0, 1.6, 0), // Eye height + socketOffset: vm.Vector3(0.5, 0, 0), // Over-the-shoulder + enablePositionLag: true, + positionLagSpeed: 8.0, + cameraNode: cameraNode, +); + +characterNode.addComponent(cameraArm); +scene.root.add(characterNode); +scene.root.add(cameraNode); +``` + +### CameraShake + +`CameraShake` implements a trauma-decay model driven by deterministic simplex noise for organic multi-axis camera shake (explosions, footsteps, hits). + +```dart +final shake = CameraShake(decayRate: 1.2, frequency: 25.0); + +// Add trauma on hit +shake.addTrauma(0.6); + +// Inside game loop +final offset = shake.update(deltaSeconds); +cameraNode.localTransform = baseTransform * offset.toMatrix4(); +``` + +## Character movement and steering + +### ThirdPersonControllerComponent + +`ThirdPersonControllerComponent` handles kinematic movement, sprint multipliers, turn smoothing, ground snapping with raycasts, slope sliding, coyote time, and buffered jumps. Input expects `+Y` as forward in 3D. + +```dart +final playerNode = Node(); +final controller = ThirdPersonControllerComponent( + walkSpeed: 4.5, + runMultiplier: 1.8, + jumpVelocity: 7.0, + groundPlaneHeight: 0.0, // Optional fallback floor +); +playerNode.addComponent(controller); + +// When using VirtualJoystick (where up is -Y in screen space), invert Y: +// controller.setMoveInput(vm.Vector2(joystickDir.x, -joystickDir.y), isRunning: isSprinting); +if (jumpPressed) controller.jump(); +``` + +### Autonomous Steering Behaviors + +`Steering` provides math helpers for NPC navigation, flocking, and crowd dynamics. + +```dart +// Seek target +final seekForce = Steering.seek(npcPos, npcVel, targetPos, maxSpeed: 4.0); + +// Arrive smoothly +final arriveForce = Steering.arrive(npcPos, npcVel, targetPos, slowingRadius: 3.0); + +// Flocking separation +final sepForce = Steering.separation(npcPos, npcVel, neighborPositions, desiredDistance: 1.5); +``` + +## Dynamic environments and atmosphere + +### DayNightCycleComponent + +`DayNightCycleComponent` moves the sun along a realistic solar arc given latitude and time of day, evaluating sun colors, intensities, and ambient lighting transitions. + +```dart +final sunLight = DirectionalLight(); +final sunNode = Node()..addComponent(DirectionalLightComponent(sunLight)); +scene.root.add(sunNode); + +final skyCycle = DayNightCycleComponent( + timeOfDay: 14.5, // 2:30 PM + timeSpeed: 0.1, // Progress 0.1 hours per second + latitude: 34.0, + sunLightNode: sunNode, +); +scene.root.addComponent(skyCycle); +``` + +### WaterSurfaceComponent + +`WaterSurfaceComponent` evaluates multi-harmonic Gerstner trochoidal waves for water surfaces and floating buoyancy queries. + +```dart +final water = WaterSurfaceComponent(); +final surface = water.evaluateAt(vm.Vector2(playerPos.x, playerPos.z)); +final waterHeight = surface.displacement.y; +final waterNormal = surface.normal; +``` + +## Immediate-mode debug visualization + +`DebugDraw` provides static immediate-mode line, ray, box, sphere, and axis drawing utilities for physics debugging and AI visualizers. + +```dart +DebugDraw.line(startPos, endPos, color: vm.Vector4(1, 0, 0, 1)); +DebugDraw.box(aabb, color: vm.Vector4(0, 1, 0, 1)); +DebugDraw.sphere(center, 1.0, color: vm.Vector4(0, 0, 1, 1)); +DebugDraw.axes(node.globalTransform, size: 2.0); + +// Render debug lines +final debugMesh = DebugDraw.flushMesh(); +if (debugMesh != null) { + debugNode.mesh = Mesh(debugMesh, UnlitMaterial()); +} +``` diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md new file mode 100644 index 0000000..63fb1ea --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md @@ -0,0 +1,166 @@ +--- +name: flutter_scene-looks +version: 4 +description: Give a flutter_scene render a deliberate, polished look. Use this whenever a scene looks flat, dull, or washed out, or whenever the ask is to make it look good, because a good look is lighting plus post-processing, not geometry. Ships copy-paste EnvironmentSettings presets that configure the whole stack coherently. +--- + +# Making flutter_scene look good + +The single most common reason a flutter_scene render looks amateur is that the post-processing stack was left at defaults. flutter_scene has a deep lit-and-post pipeline (image-based lighting, tone mapping, bloom, lens flares, ambient occlusion, screen-space reflections, fog, god rays, depth of field, color grading, vignette, film grain). Out of the box almost all of it is off, so a bare scene is technically correct and visually flat. + +**The insight: a polished look is lighting and post-processing, not geometry.** Better meshes will not fix a flat render. Do not spend effort on modeling detail when the scene reads dull; spend it on the look. And a look is a *coherent* set of choices, not twelve knobs turned independently. Turning bloom, AO, SSR, fog, grain, and grading up one at a time, each by feel, lands in muddy incoherent territory. Pick one deliberate preset and paste it whole. + +## Apply a look in one line + +Every scene-wide look field lives on one aggregate value, `EnvironmentSettings`, assigned through a single setter: + +```dart +scene.environmentSettings = EnvironmentSettings(/* fields below */); +``` + +That one assignment configures tone mapping, exposure, image-based-lighting intensity, and the entire post stack, **including** fog, god rays, depth of field, and auto exposure. You do not need to touch `scene.fog`, `scene.godRays`, `scene.depthOfField`, or `scene.autoExposure` separately; those are the live per-effect objects, but `EnvironmentSettings` carries all of their fields and applies them for you. Every effect is off by default, so a preset only names the fields it turns on. + +`EnvironmentSettings` is also a blendable snapshot. Read the current look with `scene.environmentSettings`, and cross-fade two looks with `EnvironmentSettings.lerp(a, b, t)` driven from an animation. That is how you transition day to night or ramp an effect in. + +## Lights are separate, and still matter + +`EnvironmentSettings` covers image-based lighting (the `environment` map) and the whole post stack, but **direct lights are not part of it.** Shadows, god rays, and any strong key light come from the scene's lights, set separately: + +```dart +scene.directionalLight = DirectionalLight( + direction: vm.Vector3(-0.4, -1.0, -0.3), + intensity: 4.0, + castsShadow: true, // shadows are off until you ask +); +``` + +An unset `scene.environment` still resolves to a default studio IBL, so a `PhysicallyBasedMaterial` is always lit. But a scene with no direct light is soft and shadowless. God rays require a shadow-casting `DirectionalLight`; shadows require `castsShadow: true` on the light. + +## The four looks + +Paste one whole. Each is a real `EnvironmentSettings` literal; import `package:vector_math/vector_math.dart as vm` for the `Vector3` color fields. AO, SSR, god rays, and depth of field require a `PerspectiveCamera` (the only built-in camera). + +### showcase + +Clean, bright product-viz beauty. Punchy tone mapping, soft bloom on highlights, grounded contact occlusion, and real reflections. The default reach-for-it look. + +```dart +scene.environmentSettings = EnvironmentSettings( + toneMapping: ToneMappingMode.aces, + exposure: 1.0, + bloomEnabled: true, + bloomThreshold: 1.1, + bloomIntensity: 0.2, + bloomScatter: 0.7, + ambientOcclusionEnabled: true, + ambientOcclusionMethod: AmbientOcclusionMethod.groundTruth, + ambientOcclusionBentNormals: true, + ambientOcclusionSpecularMode: SpecularAmbientOcclusionMode.bentCone, + ambientOcclusionIntensity: 1.0, + screenSpaceReflectionsEnabled: true, + screenSpaceReflectionsIntensity: 1.0, + vignetteEnabled: true, + vignetteIntensity: 0.25, +); +``` + +### stylized + +Vivid and graphic. Saturated, slightly warm, glowing, flatter shading (no heavy occlusion or reflections). For playful or illustrative scenes. + +```dart +scene.environmentSettings = EnvironmentSettings( + toneMapping: ToneMappingMode.aces, + colorGradingEnabled: true, + saturation: 1.25, + contrast: 1.1, + brightness: 1.05, + temperature: 0.1, + bloomEnabled: true, + bloomThreshold: 0.9, + bloomIntensity: 0.28, + bloomScatter: 0.8, + vignetteEnabled: true, + vignetteIntensity: 0.2, +); +``` + +### moody + +Dark, cinematic, atmospheric. Lower exposure, cool graded, foggy, heavy vignette, subtle grain and aberration, deep occlusion. God rays if the scene has a shadow-casting sun. Use a cool horizon-colored fog. + +```dart +scene.environmentSettings = EnvironmentSettings( + toneMapping: ToneMappingMode.aces, + exposure: 0.8, + colorGradingEnabled: true, + contrast: 1.15, + saturation: 0.9, + temperature: -0.1, + fogEnabled: true, + fogMode: FogMode.exponential, + fogColor: vm.Vector3(0.05, 0.06, 0.09), + fogDensity: 0.03, + ambientOcclusionEnabled: true, + ambientOcclusionMethod: AmbientOcclusionMethod.groundTruth, + ambientOcclusionIntensity: 1.2, + ambientOcclusionPower: 1.8, + vignetteEnabled: true, + vignetteIntensity: 0.6, + vignetteRadius: 0.6, + filmGrainEnabled: true, + filmGrainIntensity: 0.25, + chromaticAberrationEnabled: true, + chromaticAberrationIntensity: 0.15, + godRaysEnabled: true, // needs scene.directionalLight with castsShadow: true + godRaysIntensity: 1.0, + godRaysDensity: 0.6, + godRaysColor: vm.Vector3(1.0, 0.95, 0.85), +); +``` + +### clean + +Neutral and honest. Minimal post, no grading, no bloom, no vignette, just correct tone mapping and gentle grounding occlusion. The right look for an editor, an inspector, a UI-embedded viewer, or anywhere you want an accurate read of the actual material. + +```dart +scene.environmentSettings = EnvironmentSettings( + toneMapping: ToneMappingMode.pbrNeutral, // the engine default + exposure: 1.0, + ambientOcclusionEnabled: true, + ambientOcclusionIntensity: 0.8, + ambientOcclusionHalfResolution: true, +); +``` + +## Tuning from a preset + +Start from the nearest look, then move one field at a time. + +- **Too dim or too bright overall.** Change `exposure` (default 1.0), not per-light intensity. Or turn on `autoExposureEnabled: true` to let the scene meter itself. +- **Highlights not glowing.** Lower `bloomThreshold` toward 1.0 or below, or raise `bloomIntensity`. `bloomScatter` widens the glow. +- **Want a lens flare off a bright source.** Turn on `lensFlareEnabled` (needs `bloomEnabled`). Keep `lensFlareIntensity` modest and drop `lensFlareHaloIntensity` first if the flare washes the frame; the halo is the broad wash, the ghosts are the crisp chain. +- **Reads flat and ungrounded.** `ambientOcclusionEnabled: true`. Use `AmbientOcclusionMethod.groundTruth` for quality, keep `ambientOcclusionHalfResolution: true` for cost. +- **Colors feel wrong.** Turn on `colorGradingEnabled` and reach for `saturation`, `contrast`, `temperature`, `tint` before anything else. +- **Wrong tone-map feel.** `ToneMappingMode.aces` is contrasty and filmic, `pbrNeutral` (default) preserves hue and saturation, `agx` is the most neutral highlight rolloff. Do not reintroduce an `exposure: 2.0` hack; that was an artifact of an older renderer. + +## Micro-surface metrics and anti-waxiness tuning + +When tuning procedural materials or custom shaders, use surface metrics and exposure discipline to eliminate waxiness, plastic reads, or harsh aliasing: + +- **Surface reads like smooth plastic.** Increase high-frequency micro-scale variation. Low 1-pixel luminance gradients `(|dL/dx| + |dL/dy|) / 2` indicate untextured or overly smooth surfaces. +- **Blotchy macro clouds.** Balance high-frequency and low-frequency energy ratios (`hf/lf`). High variance with low fine detail indicates large-scale noise patches without adequate surface grain. +- **Harsh normal-map glitter or torn noise.** Check terminator crossing behavior under low grazing light angles (sun low on the horizon). Over-amplified normal maps flip adjacent pixels between full light and full shadow. +- **Colors look washed out in bright areas.** Tone mapping curves compress channel differences near the shoulder. A surface reading high brightness with low saturation is often over-exposed rather than under-pigmented; reduce exposure to restore natural material saturation. + +## Look tools that are not on EnvironmentSettings + +Anti-aliasing, reflection probes, and planar reflectors affect the look but are set outside `EnvironmentSettings`, so a preset does not turn them on. + +- **Anti-aliasing** is a `Scene` field. The default `AntiAliasingMode.auto` already picks `msaa` where the backend supports it and `fxaa` otherwise, so edges are handled. Reach for `scene.antiAliasingMode = AntiAliasingMode.smaa` when `fxaa` looks mushy (it blurs texture detail) and `msaa` is unavailable; SMAA keeps edges clean without the blur, at ~3x fxaa cost. +- **Reflection probes** capture true local reflections that SSR cannot, because SSR only reflects what is on screen. Attach a `ReflectionProbeComponent` to a node placed at the reflective spot (a room, a mirror ball); it captures the surroundings into a parallax-corrected box and blends with the environment. The capture renders the scene six times, so it happens once on activate (or on an explicit `requestCapture()`), never per frame. +- **Planar reflectors** render a true per-frame mirror for one flat surface (a mirror, a glossy floor), which neither SSR nor a probe can produce. Attach a `PlanarReflectorComponent` to the mirror node and give the surface a `.fmat` material declaring `engine_inputs: [ planar_reflection ]`; the component renders the scene once more per frame from the reflected camera and the material samples it with `GetPlanarReflection()`. That extra scene render is the cost, so bound it with `resolutionScale` (default 0.5) and `layerMask`. See `references/looks.md` for all three. + +## Cost + +The post stack is not free. AO, SSR, depth of field, and god rays each add screen-space passes, and mobile and web are the budget. Keep `ambientOcclusionHalfResolution: true`, prefer `DepthOfFieldQuality.low` on mobile, and do not stack SSR plus god rays plus depth of field on a low-end target without profiling. The `clean` look is nearly free; `moody` is the heaviest. See `references/looks.md` for the full per-effect knob reference, defaults, and cost notes. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/references/looks.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/references/looks.md new file mode 100644 index 0000000..86823c8 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/references/looks.md @@ -0,0 +1,290 @@ +# The look stack, knob by knob + +Every field here is a constructor argument on `EnvironmentSettings` (`lib/src/environment_settings.dart`). Assigning `scene.environmentSettings = EnvironmentSettings(...)` applies all of them at once. Defaults are the constructor defaults; a preset only names what it changes. Direct lights (`scene.directionalLight` and friends) are separate and not covered by `EnvironmentSettings`. + +Color fields are `vm.Vector3` (import `package:vector_math/vector_math.dart as vm`). + +--- + +## Base look + +| Field | Default | What it does | +| --- | --- | --- | +| `toneMapping` | `ToneMappingMode.pbrNeutral` | HDR to display operator. `pbrNeutral` preserves hue/saturation, `aces` is contrasty and filmic, `agx` has the gentlest highlight rolloff, `reinhard`/`linear` are simpler references. | +| `exposure` | `1.0` | Linear scene exposure multiplier. The one knob for overall brightness. Do not use `2.0`; that was an old-renderer hack. | +| `environmentIntensity` | `1.0` | Scales image-based lighting (the environment map) contribution. | +| `agxWhite` | `16.29` | AgX white point, only meaningful with `ToneMappingMode.agx`. | +| `agxContrast` | `1.25` | AgX contrast, only with `agx`. | + +`environment` (an `EnvironmentMap?`) and the sky fields are also on `EnvironmentSettings`, but building environment maps is the domain of the idioms skill; a null environment resolves to a default studio IBL. + +## Auto exposure (`autoExposure*`) + +Meters the frame and multiplies on top of `exposure`. Off by default. + +| Field | Default | What it does | +| --- | --- | --- | +| `autoExposureEnabled` | `false` | Turn metering on. | +| `autoExposureStrength` | `0.55` | How fully it drives toward the metered target (0 = none, 1 = full). | +| `autoExposureCompensation` | `0.0` | EV bias applied after metering. | +| `autoExposureMinEv` / `autoExposureMaxEv` | `-4.0` / `4.0` | Clamp range for the adaptation. | +| `autoExposureSpeedUp` / `autoExposureSpeedDown` | `3.0` / `1.0` | Adaptation rate brightening vs darkening. | + +## Bloom (`bloom*`) + +Blooms bright pixels. Off by default. The cheapest way to make a scene feel lit rather than rendered. + +| Field | Default | What it does | +| --- | --- | --- | +| `bloomEnabled` | `false` | Turn bloom on. | +| `bloomThreshold` | `1.0` | Brightness above which a pixel blooms. Lower for more glow. | +| `bloomIntensity` | `0.15` | Strength of the added glow. | +| `bloomScatter` | `0.7` | Spread of the glow, wider values feel dreamier. | + +### Lens flares (`lensFlare*`) + +Ghost chains and a halo ring off the bloom pyramid, for bright emissive sources and sun disks. Rides the bloom chain, so `bloomEnabled` must be on and the flare scales with `bloomIntensity`. Off by default. A little goes a long way; a strong source with high intensity/halo washes the frame. + +| Field | Default | What it does | +| --- | --- | --- | +| `lensFlareEnabled` | `false` | Turn flares on. Needs `bloomEnabled`. | +| `lensFlareIntensity` | `1.0` | Strength of the flare features relative to the bloom. | +| `lensFlareGhostCount` | `4` | Internal-reflection ghosts along the line through the screen center (clamped to 8 at render). | +| `lensFlareGhostSpacing` | `0.3` | Spacing between ghosts, as a fraction of the distance to the center. | +| `lensFlareHaloRadius` | `0.35` | Halo ring radius in screen UV units. | +| `lensFlareHaloIntensity` | `1.0` | Halo strength relative to the ghosts. `0` disables the halo. The halo is what washes the whole frame, so tame it first. | +| `lensFlareChromaticAberration` | `0.005` | Radial color dispersion of the flare features. | + +## Color grading (`colorGrading*`) + +Post-tone-map color shaping. Off by default. Reach here to change the *mood* of the color rather than the exposure. + +| Field | Default | What it does | +| --- | --- | --- | +| `colorGradingEnabled` | `false` | Turn grading on. | +| `brightness` | `1.0` | Multiplicative brightness. | +| `contrast` | `1.0` | Contrast around mid-gray. | +| `saturation` | `1.0` | Color saturation. Above 1 is vivid, below 1 desaturates toward gray. | +| `temperature` | `0.0` | Warm (positive) to cool (negative) white balance. | +| `tint` | `0.0` | Green to magenta balance. | +| `lift` / `gamma` / `gain` | `Vector3(0)` / `Vector3(1)` / `Vector3(1)` | Per-channel shadow/mid/highlight color control (lift-gamma-gain). | +| `colorGradingLut` | `null` | A `ColorLut` (from a `.cube` file) applied after tone mapping. Independent of `colorGradingEnabled`. | +| `colorGradingLutBlend` | `1.0` | LUT mix amount. | + +## Vignette (`vignette*`) + +Darkens the frame edges. Off by default. Small amounts read as cinematic; large amounts as a peephole. + +| Field | Default | What it does | +| --- | --- | --- | +| `vignetteEnabled` | `false` | Turn vignette on. | +| `vignetteIntensity` | `0.5` | Darkening strength at the edge. | +| `vignetteRadius` | `0.75` | How far in the darkening starts (smaller = tighter, more closed-in). | +| `vignetteSmoothness` | `0.5` | Falloff softness of the edge. | + +## Chromatic aberration (`chromaticAberration*`) + +Splits color channels toward the edges. Off by default. A touch adds a lens feel; too much looks broken. + +| Field | Default | What it does | +| --- | --- | --- | +| `chromaticAberrationEnabled` | `false` | Turn it on. | +| `chromaticAberrationIntensity` | `0.2` | Channel-separation strength. | + +## Film grain (`filmGrain*`) + +Adds animated grain. Off by default. Sells a moody or analog look and hides banding in dark gradients. + +| Field | Default | What it does | +| --- | --- | --- | +| `filmGrainEnabled` | `false` | Turn it on. | +| `filmGrainIntensity` | `0.3` | Grain strength. | + +## Ambient occlusion (`ambientOcclusion*`) + +Screen-space contact darkening. Off by default. Requires a `PerspectiveCamera`. The single biggest upgrade for "grounded" vs "floating". Half-resolution by default to stay affordable. + +| Field | Default | What it does | +| --- | --- | --- | +| `ambientOcclusionEnabled` | `false` | Turn AO on. | +| `ambientOcclusionMethod` | `AmbientOcclusionMethod.obscurance` | `obscurance` is the cheap default; `groundTruth` (GTAO) is higher quality and needed for bent normals. | +| `ambientOcclusionIntensity` | `1.0` | Darkening strength. | +| `ambientOcclusionRadius` | `0.33` | World-space sampling radius. | +| `ambientOcclusionPower` | `1.5` | Contrast of the occlusion curve. | +| `ambientOcclusionBias` | `0.07` | Self-occlusion rejection. | +| `ambientOcclusionBentNormals` | `false` | Compute bent normals (needs `groundTruth`); improves indirect lighting direction and enables `bentCone` specular AO. | +| `ambientOcclusionSpecularMode` | `SpecularAmbientOcclusionMode.none` | `simple` occludes reflections cheaply; `bentCone` is directional and needs bent normals. | +| `ambientOcclusionHalfResolution` | `true` | Compute at half res. Keep on unless AO edges look too coarse. | +| `ambientOcclusionIndirectLight` | `0.0` | Above 0 turns on screen-space global illumination (SSGI) bounce; expensive. Its radiance history reprojects, so the bounce stays put under camera motion (object motion still lags). | +| `ambientOcclusionMultiBounce` | `0.0` | Approximate multi-bounce darkening recovery. | +| `ambientOcclusionSampleCount` | `16` | Samples for the `obscurance` method. | +| `ambientOcclusionSliceCount` / `ambientOcclusionStepsPerSlice` | `3` / `3` | GTAO slice sampling. | +| `ambientOcclusionDetail` | `0.5` | Fine-detail term weight (obscurance). | +| `ambientOcclusionHorizonAngle` | `0.06` | Horizon rejection angle. | +| `ambientOcclusionThickness` / `ambientOcclusionThicknessHeuristic` | `0.5` / `0.004` | Depth thickness assumptions for occlusion. | +| `ambientOcclusionDirectLightAffect` | `0.0` | How much AO also dims direct light. | +| `ambientOcclusionVisibilityBitmask` | `false` | Bitmask visibility estimator. | +| `ambientOcclusionDepthMipChain` | `false` | Build a depth mip chain for wide-radius sampling. | + +## Screen-space reflections (`screenSpaceReflections*`) + +Reflects on-screen geometry. Off by default. Requires a `PerspectiveCamera`. Adds realism to floors, water, and glossy surfaces, but only reflects what is on screen. + +| Field | Default | What it does | +| --- | --- | --- | +| `screenSpaceReflectionsEnabled` | `false` | Turn SSR on. | +| `screenSpaceReflectionsIntensity` | `1.0` | Reflection strength. | +| `screenSpaceReflectionsMaxDistance` | `24.4` | Max world-space ray distance. | +| `screenSpaceReflectionsThickness` | `0.46` | Assumed surface thickness for hit tests. | +| `screenSpaceReflectionsStride` | `9.0` | March step size (larger = faster, coarser). | +| `screenSpaceReflectionsMaxSteps` | `90` | Ray-march step budget. | +| `screenSpaceReflectionsBlur` | `0.3` | Roughness-based blur of the reflection. | +| `screenSpaceReflectionsDistanceFadeStart` | `0.0` | Where reflections start fading with distance. | +| `screenSpaceReflectionsResolutionScale` | `1.0` | Compute resolution scale; drop below 1 to save cost. | + +## Fog (`fog*`) + +Distance and height fog, evaluated in linear HDR before tone mapping. Off by default. Needs both `fogEnabled` and a non-`none` `fogMode`. Applies to lit and unlit materials; the skybox is left unfogged, so set `fogColor` to your horizon color for distant blending. + +| Field | Default | What it does | +| --- | --- | --- | +| `fogEnabled` | `false` | Turn fog on. | +| `fogMode` | `FogMode.exponential` | `none`, `linear`, `exponential`, `exponentialSquared`. Must be non-`none` to render. | +| `fogColor` | `Vector3(0.6, 0.7, 0.8)` | Fog tint. Match your sky/horizon. | +| `fogDensity` | `0.02` | Density for the exponential modes. | +| `fogStart` / `fogEnd` | `0.0` / `200.0` | Near/far bounds for `linear` mode. | +| `fogSkyColorInfluence` | `0.0` | Blend fog color toward the sky color. | +| `fogMaxOpacity` | `1.0` | Cap on how opaque fog gets. | +| `fogHeight` / `fogHeightFalloff` | `0.0` / `0.0` | Height-fog band and falloff. | +| `fogSunInScatter` / `fogSunInScatterExponent` | `0.0` / `8.0` | Sun in-scatter glow through the fog. | +| `fogCutoffDistance` | `0.0` | Distance beyond which fog stops accumulating. | + +## God rays (`godRays*`) + +Volumetric light shafts. Off by default. Requires a shadow-casting `DirectionalLight` and a `PerspectiveCamera`; without a shadow-casting sun there is nothing to shaft. + +| Field | Default | What it does | +| --- | --- | --- | +| `godRaysEnabled` | `false` | Turn shafts on. | +| `godRaysIntensity` | `1.0` | Shaft strength. | +| `godRaysDensity` | `0.5` | Medium density the light scatters through. | +| `godRaysAnisotropy` | `0.7` | Forward-scatter bias (higher = tighter shafts toward the sun). | +| `godRaysStepCount` | `24` | March steps; higher is smoother and costlier. | +| `godRaysMaxDistance` | `200.0` | Max shaft distance. | +| `godRaysJitter` | `1.0` | Dither to hide banding. | +| `godRaysColor` | `Vector3(1)` | Shaft tint. | + +## Depth of field (`depthOfField*`) + +Physically parameterized lens blur. Off by default. Requires a `PerspectiveCamera`. Great for a hero shot, wasteful for a full interactive scene. + +| Field | Default | What it does | +| --- | --- | --- | +| `depthOfFieldEnabled` | `false` | Turn DoF on. | +| `depthOfFieldFocusDistance` | `10.0` | World distance in sharp focus. | +| `depthOfFieldFStop` | `2.8` | Aperture; lower = shallower focus, more blur. | +| `depthOfFieldFocalLength` | `0.0` | Lens focal length (0 derives from FOV). | +| `depthOfFieldSensorHeight` | `0.024` | Sensor height in meters (35mm-ish). | +| `depthOfFieldBlurScale` | `1.0` | Overall blur multiplier. | +| `depthOfFieldMaxForegroundBlur` / `depthOfFieldMaxBackgroundBlur` | `24.0` / `32.0` | Blur radius caps. | +| `depthOfFieldBladeCount` | `0` | Aperture blades for bokeh shape (0 = round). | +| `depthOfFieldBladeRotation` / `depthOfFieldBladeCurvature` | `0.0` / `0.0` | Bokeh blade shaping. | +| `depthOfFieldQuality` | `DepthOfFieldQuality.medium` | `low` (16 taps, mobile/web), `medium` (32 taps + postfilter), `high` (48 taps). | + +--- + +## Not on `EnvironmentSettings` + +Two look-affecting settings live outside the `EnvironmentSettings` snapshot: anti-aliasing is a `Scene` field, and reflection probes are a scene-graph component. Set them directly. + +### Anti-aliasing (`Scene.antiAliasingMode`) + +Edge anti-aliasing. `AntiAliasingMode.auto` is the default: it picks `msaa` where the backend supports it and `fxaa` otherwise. Set it directly, not through `EnvironmentSettings`. + +```dart +scene.antiAliasingMode = AntiAliasingMode.smaa; +``` + +| Mode | What it does | +| --- | --- | +| `none` | No anti-aliasing; native-resolution edges. | +| `msaa` | 4x MSAA on the scene pass, the best geometry-edge quality and cheap on mobile GPUs, but not supported on every Flutter GPU backend (falls back to `fxaa`). Check `Scene.isAntiAliasingModeSupported` and read `Scene.effectiveAntiAliasingMode`. | +| `fxaa` | One post pass over the tone-mapped image, supported everywhere, but softens all high-contrast edges including texture detail. | +| `smaa` | SMAA 1x, three post passes, supported everywhere. Reconstructs edge shapes so edges are cleaner than `fxaa` with far less texture blurring, at roughly 3x the `fxaa` cost. Reach for it when `fxaa` looks mushy and `msaa` is unavailable. | +| `auto` | `msaa` where supported, else `fxaa`. | + +### Reflection probes (`ReflectionProbeComponent`) + +SSR only reflects what is currently on screen. A reflection probe captures the surroundings from a point into a local, parallax-corrected environment, so off-screen geometry reflects correctly inside a bounded box (a mirror ball, a glossy floor in a room). It is a `Component` attached to a `Node`, not an `EnvironmentSettings` field. + +```dart +final probe = Node() + ..localTransform = vm.Matrix4.translation(vm.Vector3(0, 1, 0)); // reflective spot +probe.addComponent(ReflectionProbeComponent( + extents: vm.Vector3(4, 3, 4), // box half-extents (the influence + parallax volume) +)); +scene.add(probe); +``` + +| Constructor arg | Default | What it does | +| --- | --- | --- | +| `extents` | `Vector3.all(5.0)` | Half-extents of the world-axis-aligned box that is both the influence volume and the parallax proxy. | +| `blendDistance` | `1.0` | Distance over which the probe cross-fades with the environment at the box edge. | +| `priority` | `10.0` | Which probe wins where several overlap. | +| `weight` | `1.0` | Contribution scale in the blend. | +| `faceResolution` | `128` | Cubemap face resolution of the capture. | +| `captureOnActivate` | `true` | Capture once when the probe joins the scene. Call `requestCapture()` to re-capture after the scene changes; the capture is a static snapshot otherwise. | + +For a one-shot environment capture with no node or parallax (e.g. to hand a captured `EnvironmentMap` to another material), `Scene.captureEnvironment(position: ...)` returns an `EnvironmentMap` directly. + +### Planar reflectors (`PlanarReflectorComponent`) + +A true mirror for one flat surface, re-rendered every frame the surface is visible: the engine renders the scene from the view camera reflected across the surface's plane (near plane clamped to the mirror, so nothing behind it leaks in) and hands the capture to the surface's material. Use it for mirrors and glossy floors where SSR's on-screen-only reflections or a probe's static capture are not enough. + +Two pieces pair up. The component goes on the mirror node (the plane is the node's local `+Y` through its transform, or an explicit `localNormal`): + +```dart +final mirror = Node(mesh: Mesh(PlaneGeometry(width: 10, depth: 10), mirrorMaterial)) + ..addComponent(PlanarReflectorComponent()); +scene.add(mirror); +``` + +And the surface's material is a `.fmat` that declares the `planar_reflection` engine input and samples `GetPlanarReflection()` (mirrored scene color in rgb, `a` 1 while a capture is bound; fall back to the environment reflection at `a == 0`). A worked mirror lives at `examples/flutter_app/assets/planar_mirror.fmat`. + +| Constructor arg | Default | What it does | +| --- | --- | --- | +| `resolutionScale` | `0.5` | Capture resolution relative to the view (clamped `0.1..1.0`). The fragment-cost lever. | +| `layerMask` | all layers | What renders into the capture. The draw-cost lever. | +| `reflectionGroupId` | `-1` | Co-planar surfaces sharing a non-negative id share one capture per frame; `-1` means an own capture. | +| `clipBias` | `1e-3` | World-space offset of the clip plane in front of the mirror, keeping the surface itself out of the capture. | +| `localNormal` | local `+Y` | The mirror plane's facing direction in node space. | + +The capture is a second scene submission per reflection group per frame: its CPU and draw-call cost scales with scene complexity, not just resolution. It reuses the frame's shadow atlas and runs without screen-space post; reflectors seen inside a capture draw their base look, so captures never recurse. + +--- + +## Cost and budget + +Post effects are screen-space passes; they cost per output pixel, not per triangle. Rough order, cheapest first: + +- **Nearly free.** Tone mapping, exposure, color grading, vignette, chromatic aberration, film grain, bloom (with lens flares). The `clean` and `stylized` looks live here. +- **Moderate.** Ambient occlusion (keep `ambientOcclusionHalfResolution: true`), fog, `fxaa`, `smaa` (~3x `fxaa`). `msaa` is nearly free on mobile GPUs but costs more elsewhere. +- **Expensive.** SSR, god rays, depth of field, and especially SSGI (`ambientOcclusionIndirectLight > 0`). Each adds ray-marching or gather passes. A reflection probe's capture renders the scene six times, so capture on activate or an occasional `requestCapture()`, never per frame. + +Budget guidance: + +- **Mobile and web are the ceiling.** A look that is smooth on desktop can tank a phone. Profile the target, do not assume. +- **Keep AO at half resolution** unless the coarse edges actually show. Full-res AO rarely earns its cost. +- **Do not stack the expensive effects blindly.** SSR plus god rays plus depth of field together on a low-end device needs profiling; drop one or lower its resolution/step budget (`screenSpaceReflectionsResolutionScale`, `godRaysStepCount`, `DepthOfFieldQuality.low`). +- **Depth of field is a hero-shot tool.** It reads as intentional on a framed still and as a smear on a free-moving interactive camera. Prefer it where the camera is controlled. +- **Bloom and grading buy the most look per cost.** When you need "flashier" cheaply, reach for these before the screen-space passes. + +## Why each look is built the way it is + +**showcase** aims for a clean, believable, flattering render, the default for showing a model off. `aces` tone mapping gives contrast and pop; soft bloom (threshold just above 1.0) lights the highlights without haze; GTAO with bent normals and `bentCone` specular AO grounds the object and tightens reflections in its cavities; SSR adds real floor and surface reflections; a light vignette focuses the eye. Every choice supports "look at this object", nothing calls attention to itself. + +**stylized** trades realism for graphic punch. It leans on color (saturation up, a warm push, contrast up) and glow (a lower bloom threshold so more of the frame blooms) and deliberately *omits* AO and SSR, because heavy occlusion and reflection read as realism and fight the flatter, poppier intent. Cheap to run, since it is all near-free passes. + +**moody** is the atmospheric, cinematic end. Lower exposure and cool grading set a somber base; exponential fog with a dark horizon color adds depth and hides the far plane; strong AO deepens the shadows; a tight heavy vignette closes the frame; film grain and a touch of chromatic aberration add texture and a lens feel; god rays (given a shadow-casting sun) add drama. It is the most expensive look; on a tight budget, drop god rays first, then SSR if present. + +**clean** is the honest look, for when the render must show the *actual* material and lighting without editorializing: an editor viewport, an inspector, a UI-embedded preview. Default `pbrNeutral` tone mapping preserves hue and saturation, and the only effect on is gentle half-res AO for grounding. No bloom, grading, or vignette, because each of those changes what the color and brightness actually are, which is exactly what an accurate preview must not do. Also the cheapest look by far. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/SKILL.md new file mode 100644 index 0000000..c833f8e --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/SKILL.md @@ -0,0 +1,52 @@ +--- +name: flutter_scene-performance +version: 2 +description: Make a flutter_scene app hit frame budget. Use whenever a scene janks, stutters, or drops frames, or when the ask is to make it faster or run on mobile or web, because code-driven scenes are reliably slow and the fix depends on which thread is over budget, not on a guessed poly count. +--- + +# Making flutter_scene fast + +A code-driven flutter_scene scene is reliably slow, and the usual reason is that nothing told you the budget or what to fix first, so optimization starts as guesswork. Guessing wastes iterations and often makes the wrong thread slower. This skill replaces the guessing with a budget, a way to measure it, and a fixed order to apply fixes in. + +**The one thing to internalize: measure the actual frame, find which of the two threads is over budget, then fix that thread. Do not target a triangle or draw-call number from memory.** There is no built-in poly budget, and the same scene can be fast on desktop and jank on a phone. The number that matters is milliseconds per frame on the real target. + +## The budget + +A frame has a fixed wall-clock budget set by the refresh rate. + +- **60 fps is 16.6 ms per frame. 120 fps is 8.3 ms.** Miss it and the frame janks. +- That budget is split across **two threads**, and either one blowing it drops the frame: + - **UI thread** runs your Dart. flutter_scene walks the scene graph, culls, updates components, and builds the render here. + - **Raster thread** is where Impeller draws the built frame on the GPU. The whole post-processing stack (ambient occlusion, reflections, depth of field, god rays, bloom) lands here. +- **Mobile and web are the real constraint.** Desktop GPUs hide a lot; a scene that runs smooth on a laptop can miss budget badly on a phone or in a browser. Profile on the lowest target you must support. + +## Measure first (do not skip this) + +flutter_scene has **no built-in stats API**. There is no `scene.frameTime`, no draw-call counter. The editor MCP `get_app_state` reports only lifecycle (launching/running), not frame timing. So measurement is Flutter's own tooling. + +1. **Run in profile mode.** `flutter run --profile --enable-flutter-gpu`. Debug-mode timings are meaningless for performance (assertions, no JIT-to-AOT optimization, extra checks), so never judge speed in debug. +2. **Read the frame chart.** Open DevTools, go to the Performance view, and read **per-frame UI time vs raster time**. The jank frames are flagged. This single view tells you which thread is over budget, which decides everything below. +3. **Or use the performance overlay** for a quick in-app read of the two thread graphs without DevTools. +4. **Stopwatch as a coarse fallback.** A `Stopwatch` around the per-frame work gives a rough UI-thread number when you cannot open DevTools. It sees nothing on the raster thread. + +Which thread is over budget names the fix. UI over budget means too much graph/CPU work (steps a, b, c below). Raster over budget means too much GPU work (steps d, e). See `references/performance.md` for the symptom to thread diagnosis. + +## The fixed remediation order + +Apply top-down. Each step lists the real API. Do the measured-over-budget thread's steps first, but the order within is deliberate, the earlier fixes are the bigger wins. + +1. **Instancing** (UI thread). Many copies of one mesh collapse into one draw and one cull test. Build an `InstancedMesh(geometry:, material:)`, add a transform per copy with `addInstance(matrix, {color})`, and mount it with `InstancedMeshComponent`. The single biggest win for repeated geometry (foliage, crowds, tiles, debris). Per-instance frustum culling is off by default (`cullInstances: false`), so the batch is one cull test as a unit. See the `flutter_scene-procedural` skill for the full scatter pattern. + +2. **Level of detail** (both threads). `LodComponent(List)` swaps cheaper meshes as an object shrinks on screen. Each `LodLevel(geometry:, material:, screenSize:)` gives a threshold (projected size as a fraction of viewport height, highest detail first, last is the cull floor). Fewer triangles for distant objects on the GPU, and nothing drawn below the floor. Note the shadow and depth passes always draw the highest-detail level, so LOD does not lighten shadow cost. + +3. **Culling** (UI thread). `Node.frustumCulled` (default true) skips off-screen subtrees; leave it on. Set it `false` only where the cached bound is known-stale or unbounded (procedural terrain you regenerate). For whole sets, `RenderView.cullingPlanes` adds extra clip planes, and `node.layers` (default `kRenderLayerDefault`) against `RenderView.layerMask` (default `kRenderLayerAll`) skips entire layers a view should not draw. + +4. **Shrink the post stack** (raster thread). This is usually where raster time goes. Turn off effects you do not need via the `EnvironmentSettings` `*Enabled` flags, keep `ambientOcclusionHalfResolution: true`, lower `depthOfFieldQuality` toward `DepthOfFieldQuality.low`, drop `RenderView.renderScale` (or `Scene.renderScale`) below `1.0` to render fewer pixels, and step `Scene.antiAliasingMode` down (`msaa` to `fxaa` to `none`). See the `flutter_scene-looks` skill for what each knob does to the look. + +5. **Texture and material consolidation** (raster thread). Fewer distinct textures and materials means fewer state changes and binds per frame. `TextureAtlas` (with `generateSolidColorAtlasPixels` for placeholders) packs many tiles into one texture so one material covers them all; share a single `Material` instance across many nodes instead of constructing one per node; use `MaterialsVariantsComponent` to switch a model between named material sets rather than duplicating materials. + +6. **Static shadows** (raster thread). `Node.shadowStatic = true` promises a caster's geometry, material coverage, and world transform will not change while mounted, so the engine renders it into cached shadow-map tiles reused across frames instead of re-encoding every caster every frame. A large static world becomes dramatically cheaper to shadow. Flag only genuinely static content; a static node that moves shows stale shadows until its render item re-registers. + +## More depth + +`references/performance.md` expands each step with the full API and when it helps, the UI-vs-raster symptom map (what a wrong frame time points to), and the honest note on what measurement tooling actually exists. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/references/performance.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/references/performance.md new file mode 100644 index 0000000..51cd859 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/references/performance.md @@ -0,0 +1,128 @@ +# flutter_scene performance reference + +Companion to the `flutter_scene-performance` skill. The skill states the budget, the measure-first rule, and the fixed remediation order. This file expands each step with the real API, when it helps and when it does not, the diagnosis that maps a wrong frame time to a thread, and an honest account of the measurement tooling. + +Verify any symbol here against `lib/src` before relying on it; the inventory in the `flutter_scene-idioms` skill (`references/what-exists.md`) is the fuller API map. + +## The two threads, concretely + +A frame is built on the UI thread and drawn on the raster thread, and they overlap across frames (frame N rasters while frame N+1 builds). Either thread over budget drops the frame. + +- **UI thread work** is Dart. flutter_scene walks the scene graph, computes world transforms, runs frustum culling, ticks every component's `update`, and encodes the draw list. Cost scales with node count, component count, and how much per-frame Dart you run in `onTick` or component `update`. +- **Raster thread work** is the GPU. Impeller executes the encoded passes, the shadow pass, the main color pass, and every enabled screen-space post-processing pass. Cost scales with pixels drawn, overdraw, shadow-map resolution, and how many post passes are on. + +## Diagnosis, symptom to thread + +Read the two thread graphs in DevTools (or the performance overlay) and match the over-budget one to a cause. + +| Observation | Over-budget thread | Likely cause | Go to step | +| --- | --- | --- | --- | +| UI time high, raster fine | UI | Too many nodes/draws or heavy per-frame Dart | 1 instancing, 3 culling | +| UI time scales with object count | UI | Thousands of separate nodes for one repeated mesh | 1 instancing | +| UI time high with a huge static world | UI | No culling; whole graph walked every frame | 3 culling | +| Raster time high, UI fine | Raster | Too many pixels or post passes | 4 post stack, 5 consolidation | +| Raster time high, and it tracks resolution | Raster | Fill-bound; too many pixels | 4 `renderScale`, AA | +| Raster spikes only when shadows are on | Raster | Every caster re-encoded per frame | 6 static shadows | +| Raster time tracks the number of distinct materials | Raster | State-change churn from per-node materials/textures | 5 consolidation | +| Jank only on phone or web, smooth on desktop | Whichever is over budget there | Desktop GPU was hiding it | measure on the real target | + +If both threads are near budget, fix the UI thread first (steps 1 to 3); a lighter draw list also lightens the raster thread. + +## Measurement tooling, honestly + +There is **no built-in frame-stats API in flutter_scene**. No `scene.frameTime`, no draw-call count, no visible-triangle count. Do not invent one or claim one exists. + +- **Profile mode is mandatory.** `flutter run --profile --enable-flutter-gpu`. Debug builds carry assertions and skip AOT optimization, so their timings do not reflect a release build. A number taken in debug mode is not a performance number. +- **DevTools Performance view** is the primary tool. It shows per-frame UI time and raster time as two tracks, flags janky frames, and lets you expand a frame's timeline. This is what tells you which thread is over budget. +- **The performance overlay** gives the same two thread graphs in-app for a quick read without attaching DevTools. +- **A `Stopwatch`** around the per-frame work (the `onTick` body, or a component `update`) is a coarse UI-thread fallback. It cannot see the raster thread at all, so a good stopwatch number does not clear a raster-bound jank. +- **Editor MCP.** `get_app_state` reports lifecycle only (launching/running), not timing. The one place per-pass GPU timings surface is a render-graph capture (`Scene.captureRenderGraph`, or the editor MCP capture tool), whose result carries per-pass timing and lets you see which post pass is expensive. That is per-pass GPU detail, not a whole-frame counter, and the editor MCP is not connected in every project. When it is not, the DevTools loop above is fully sufficient. + +## Step 1, instancing + +`InstancedMesh` holds one `geometry`/`material` pair and one transform per copy. The whole set encodes as a single draw and, by default, a single frustum cull test. + +```dart +final mesh = InstancedMesh( + geometry: someGeometry, + material: sharedMaterial, // one material for the whole batch +); +for (final placement in placements) { + mesh.addInstance(placement.transform, color: placement.tint); // matrix is cloned +} +scene.add(Node()..addComponent(InstancedMeshComponent(mesh))); +``` + +- `addInstance(Matrix4, {Vector4? color})` returns an index; the matrix is cloned, so mutating your copy afterward is safe. Per-instance `color` is a linear RGBA multiplier. +- Edit later with `setInstanceTransform(i, m)`, `setInstanceColor(i, color)`, `removeInstanceAt(i)`, `clearInstances()`, or move the whole batch in one pass with `updateInstanceTransforms((list) { ... })`. +- **Culling default.** `cullInstances` defaults to `false`, so the batch is culled as one unit against its combined bounds, not per instance. Turn `cullInstances: true` on only for a batch spread across a large area where many instances are off-screen, since per-instance culling adds CPU work. +- **When it helps.** Repeated geometry, foliage, crowds, tiles, debris, particles-as-meshes. It is the largest UI-thread win available, because N separate nodes become one. It does nothing for a scene of distinct meshes. +- **Winding trap.** A mirrored (negative-determinant) instance edited with `updateInstanceTransforms(recomputeWinding: false)` renders inside-out. Keep instance edits orientation-preserving, or let winding recompute. + +See the `flutter_scene-procedural` skill for the full scatter-on-terrain pattern. + +## Step 2, level of detail + +`LodComponent(List)` draws one of several mesh variants per frame, chosen from how large the object appears on screen. + +```dart +node.addComponent(LodComponent([ + LodLevel(geometry: high, material: mat, screenSize: 0.4), + LodLevel(geometry: mid, material: mat, screenSize: 0.15), + LodLevel(geometry: low, material: mat, screenSize: 0.04), // set 0.0 to never cull +])); +``` + +- `screenSize` is the projected bounding-sphere diameter as a fraction of viewport height. Levels are highest detail first, strictly descending. The engine draws the highest-detail level whose threshold the object still meets, and draws nothing below the last threshold (the cull floor). +- Selection is screen-size based, so it is field-of-view aware and resolution independent, and it is per view (a split-screen frame can pick different levels per view). +- `LodComponent(levels, {lodBias = 1.0, hysteresis = 0.1, blendRange = 0.0})`. `lodBias` above `1` keeps detail farther away; `hysteresis` is a dead-band so an object on a boundary does not flip-flop; `blendRange` above `0` dither-cross-fades adjacent levels to remove the pop (honored by the built-in lit and unlit materials). +- **Limitation that matters for shadows.** The shadow and depth-prepass passes always draw the highest-detail level and ignore the LOD cull. So LOD lightens the color pass, not shadow or depth cost. A shadow-heavy scene needs step 6, not LOD. +- **Not for instanced draws.** A `LodComponent` draws a single mesh and picks one level for the whole node; it does not combine with hardware instancing. + +## Step 3, culling + +Skip work for things the camera cannot see. + +- **`Node.frustumCulled`** (default `true`) skips a subtree whose `combinedLocalBounds` do not intersect the camera frustum. Leave it on. Set it `false` only where the cached bound is known-stale or misleading (procedural geometry you regenerate, large terrain pieces). A subtree that reports no bound (skinned content, geometry without a computable bound) is treated as always visible regardless of the flag. +- **`RenderView.cullingPlanes`** (`List`, default empty) adds extra clip planes beyond the frustum, for portal or region culling. +- **Layers.** `node.layers` (a 32-bit mask, default `kRenderLayerDefault` which is layer 0, not inherited by children) against `RenderView.layerMask` (default `kRenderLayerAll`) decides whether a view draws a node at all, when `node.layers & view.layerMask != 0`. Put editor gizmos, an inset viewport's contents, or a minimap's set on their own layer and give each view the mask it needs, so a view skips whole sets cheaply. +- **When it helps.** Large worlds where much of the graph is off-screen each frame. Culling is a UI-thread win (fewer nodes encoded) that also lightens the raster thread (fewer draws). + +## Step 4, shrink the post stack + +Every screen-space effect is a raster-thread pass. Turning off what you do not need is the most direct raster win. All the scene-wide look fields live on `EnvironmentSettings` (see the `flutter_scene-looks` skill); each effect has an `*Enabled` flag, off by default. + +- **Turn effects off.** `ambientOcclusionEnabled`, `screenSpaceReflectionsEnabled`, `godRaysEnabled`, `depthOfFieldEnabled`, `bloomEnabled`, and the rest default `false`. A preset the app copied may have turned several on; drop the ones the scene does not visibly need. Ambient occlusion, screen-space reflections, god rays, and depth of field are the heavy ones. +- **Half-resolution AO.** `ambientOcclusionHalfResolution` defaults `true`; keep it. Full-resolution AO roughly doubles that pass's cost for little visible gain on most content. +- **Cheaper depth of field.** `depthOfFieldQuality` (`DepthOfFieldQuality.low`/`medium`/`high`, default `medium`) trades gather taps and cleanup passes for time. Step it down to `low` on mobile. +- **Render fewer pixels.** `Scene.renderScale` (default `1.0`), or per-view `RenderView.renderScale`, renders the scene at a fraction of resolution and upscales. Dropping to `0.75` cuts fill cost by nearly half and is often barely visible after anti-aliasing. This is the biggest lever for a fill-bound (resolution-tracking) raster time. +- **Step anti-aliasing down.** `Scene.antiAliasingMode`. `AntiAliasingMode.auto` picks `msaa` where supported else `fxaa`. `msaa` is cheap on mobile tilers and highest quality; `fxaa` is a single post pass on every backend; `smaa` is cleaner than `fxaa` but three post passes (~3x its cost), so step it down to `fxaa` or `none` on a raster-bound target; `none` is free. Read what actually runs with `Scene.effectiveAntiAliasingMode`. +- **Do not re-capture reflection probes per frame.** A `ReflectionProbeComponent` capture (or `Scene.captureEnvironment`) renders the scene six times, a large one-frame spike. Let it capture once on activate and only call `requestCapture()` when the scene visibly changes, never every frame. +- **When it helps.** Any raster-bound scene. The `clean` look in the looks skill is nearly free; a full `moody` stack (AO plus SSR plus god rays plus DoF plus grain) is the heaviest. Do not stack all of those on a low-end target without profiling. + +## Step 5, texture and material consolidation + +Every distinct material and texture is a potential state change and bind on the raster thread. Fewer of them means a shorter, cheaper draw list. + +- **`TextureAtlas`** packs many equally sized tiles (voxel faces, sprite sheets, terrain tiles) into one texture, so a single material and draw call cover every tile. Resolve a tile's UV box with `tileBounds(index)` or map a within-tile coordinate with `tileUv(index, u, v)`, write those into the mesh's texture coordinates, and build the bound material with `toMaterial()`. `generateSolidColorAtlasPixels(tileColors:, columns:, tileSize:, padding:)` builds placeholder pixels to bring the atlas path up before real art exists. +- **Share one `Material` instance** across many nodes rather than constructing a new one per node. Identical materials that are separate objects still churn binds; the same object does not. Build the material once and reuse the reference. +- **`MaterialsVariantsComponent`** switches an imported model between its named `KHR_materials_variants` sets in place (`MaterialsVariantsComponent.of(model)?.select('name')`), instead of duplicating a model per look. Read `variants` for the declared names; `select(null)` restores defaults. +- **When it helps.** Scenes whose raster time tracks the count of distinct materials or textures, tile-based worlds, and models shown in several finishes. + +## Step 6, static shadows + +Shadow casting re-encodes every caster into the shadow map each frame by default. `Node.shadowStatic = true` promises a caster will not change and lets the engine cache its shadow-map tiles across frames. + +```dart +staticWorldNode.shadowStatic = true; // set per mesh-bearing node; not inherited +``` + +- **The contract.** The node's geometry, material coverage, and world transform must not change while mounted. In return, the engine renders it into cached shadow-map tiles reused across frames instead of re-encoding it every frame. Dynamic nodes (the default) still cast per-frame shadows on top of the cache, so a moving character over a static world works. +- **Not inherited.** Set it on each mesh-bearing node, not once on a root. +- **Stale-shadow caveat.** A static node that does change (moves, remeshes, edits material coverage) shows stale shadows until its render item re-registers. Flag only genuinely static content. +- **Displacement caveat.** A material with a `vertex { }` displacement stage should stay dynamic, since its cached shadow would not follow a camera-dependent displacement. +- **When it helps.** Large static worlds with a shadow-casting `DirectionalLight`. The win scales with how many static casters you have; a mostly static level with a few moving actors is the ideal case. + +## Order and stopping + +Fix the measured over-budget thread first, top-down within it, and re-measure after each change so cause and effect stay legible. Stop when the frame chart clears budget on the real target; there is no reason to keep optimizing a thread that is already under budget while the other one janks. The whole point of measuring first is to avoid spending a step's effort on the thread that was never the problem. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/SKILL.md new file mode 100644 index 0000000..417af29 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/SKILL.md @@ -0,0 +1,134 @@ +--- +name: flutter_scene-procedural +version: 2 +description: Build flutter_scene content from code instead of asset files. Use when generating terrain, scattering vegetation or crowds, building oceans and Gerstner waves, setting up procedural skies and trees, assembling modular kits, or driving a scene from noise and instancing rather than loading a .glb. +--- + +# Procedural content in flutter_scene + +A lot of 3D work does not need an artist's `.glb` at all. Terrain, scattered foliage, debris fields, crowds, and modular buildings are cheaper and more flexible built from code, and flutter_scene has the whole path in the box. Reach for it before wiring up an asset pipeline. + +**The insight: for a code-driven scene, generate geometry and draw it instanced. That path is more reliable than loading external assets, because it has no import step, no coordinate-conversion traps, no missing-file failure modes, and one draw call for thousands of copies.** Three pieces cover almost everything: + +- **`GeometryBuilder`** (and the built-in primitives and swept paths) build custom meshes without a model file. +- **`FastNoiseLite`** drives heightmaps, placement, and displacement deterministically. +- **`InstancedMesh`** draws thousands of copies of one mesh as a single render item. + +Do not hand-pack a `ByteData` vertex buffer. The vertex layout is fixed (72 bytes unskinned, a specific attribute order) and a wrong stride fails silently with washed-out or see-through geometry. `GeometryBuilder` and `MeshGeometry.fromArrays` interleave the layout for you. + +## Imports + +Geometry and instancing live in the main barrel. **Noise is a separate barrel** and is easy to forget: + +```dart +import 'package:flutter_scene/scene.dart'; // GeometryBuilder, MeshGeometry, InstancedMesh, ... +import 'package:flutter_scene/noise.dart'; // FastNoiseLite, bakeNoiseTexture, noiseCurl3 +import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64 +``` + +## Terrain from a noise heightmap + +Sample `FastNoiseLite` on a grid, add each vertex, and wind the two triangles per cell so the lit surface faces up. Omitting normals lets the builder derive them from the actual face slopes, which is what you want for terrain. + +```dart +MeshGeometry buildTerrain({int cols = 128, int rows = 128, double spacing = 0.5}) { + final noise = FastNoiseLite(seed: 1337) + ..noiseType = NoiseType.openSimplex2 + ..fractalType = FractalType.fbm // stack octaves for natural detail + ..octaves = 5 + ..frequency = 0.02; // world units are multiplied by this + + final builder = GeometryBuilder(); + + // One vertex per grid point. getNoise2 returns roughly -1..1. + for (var r = 0; r < rows; r++) { + for (var c = 0; c < cols; c++) { + final x = c * spacing; + final z = r * spacing; + final height = noise.getNoise2(x, z) * 6.0; + builder.addVertex(vm.Vector3(x, height, z)); + } + } + + // Two triangles per cell, wound Counter-Clockwise (CCW) so the front face points +Y (up). + for (var r = 0; r < rows - 1; r++) { + for (var c = 0; c < cols - 1; c++) { + final v00 = r * cols + c; + final v10 = v00 + 1; + final v01 = v00 + cols; + final v11 = v01 + 1; + builder + ..addTriangle(v00, v01, v10) + ..addTriangle(v10, v01, v11); + } + } + + return builder.build(); +} +``` + +Attach it like any mesh: + +```dart +final terrain = Node(mesh: Mesh(buildTerrain(), PhysicallyBasedMaterial()..roughnessFactor = 1.0)); +scene.add(terrain); +``` + +If a hand-built surface renders inside-out (visible only from below, dark where lit), reverse each triangle's index order. flutter_scene's front faces wind Counter-Clockwise (CCW) in model space, matching glTF and standard conventions; never fix orientation with a per-triangle flip on an imported model, but for geometry you author yourself the winding is yours to set. + +## Natural formations and landscape recipes + +To achieve documentary realism rather than generic procedural lumps: + +1. **Footpaths are scoured trenches, not flat stripes.** A real trail is the lowest line across terrain because water and foot traffic erode it downwards. When generating heightfields, cut the trail path profile down into the terrain with banks rising away on both sides. +2. **Ridged noise for valley walls and cliffs.** Standard `FractalType.fbm` makes rolling mounds. Use `FractalType.ridged` for valley walls, mountain spurs, and cliffs to produce sharp erosion creases. +3. **Free-end Worley rock cracks.** Standard Worley noise (`F2 - F1`) creates closed polygonal loops like bathroom tile. To produce weathered rock fractures with natural free ends, multiply the cell border by a low-frequency region mask and a high-frequency grain breaker. +4. **Noise-modulated pitting.** A constant threshold radius across Worley cells places a pit in every cell, producing an artificial grid lattice. Modulate the threshold radius with an underlying Perlin field so pores vary in size and only appear in exposed weathering pockets. +5. **Macro massing for scattered gravel.** Soil wears in 0.5m to 2m zones. Modulate multi-scale pebble instances with a low-frequency massing field so gravel clusters into realistic water scour lines rather than uniform sandpaper noise. +6. **Sunk block settling and ground contact staining.** Place boulders and masonry courses 1/3 to 2/3 submerged into the sampled ground height. Use vertex colors or shader ground distance to stain the bottom 20cm of rock near the soil boundary, creating a smooth moisture transition. +7. **Oceans and Gerstner waves.** Sum 4 to 8 directional Gerstner trochoidal waves that pull vertices horizontally toward crests, producing sharp peaks and wide flat troughs. Use Beer-Lambert depth absorption (exp(-sigma_a * d)) via scene depth for turquoise to deep navy transitions, Jacobian folding for peak foam, and darken/smooth tidal sand within the shoreline wash. +8. **Trees and foliage translucency.** Extrude branch splines using `TubeGeometry` or `ExtrudeGeometry`, conserving cross-sectional area across splits (d_parent^2 = sum d_child^2). Set `Material.doubleSided = true` and add diffuse transmission in custom leaf shaders so backlit canopies glow. Apply quadratic cantilever displacement (delta_p proportional to h^2) for organic wind sway. +9. **Procedural skies and IBL synchronization.** Use `PhysicalSkySource` (`lib/src/sky_sources.dart`) with analytic Rayleigh and Mie scattering. Assign `SkyEnvironment` to `Scene.skyEnvironment` or call `EnvironmentMap.fromSky` to bake prefiltered radiance and SH-9 diffuse coefficients into the scene's IBL automatically, and assign the source to `Scene.skybox` for matching background visuals. +10. **Islands and coastal erosion.** Multiply radial distance falloff with domain-warped FBM to form organic bays, sandbars, and lagoons. Use analytical surface slopes to strip topsoil on steep cliffs while depositing golden sand and reef shoals on shallow coastal planes. + +## Scattering thousands of copies + +`InstancedMesh` holds one geometry/material pair and a transform per copy. The whole set is one pipeline and one cull test. Place instances by sampling the same terrain height so they sit on the ground. + +```dart +final rng = math.Random(7); +final scatter = InstancedMesh( + geometry: CylinderGeometry(bottomRadius: 0.0, topRadius: 0.15, height: 1.2), // a cone + material: PhysicallyBasedMaterial()..baseColorFactor = vm.Vector4(0.2, 0.5, 0.15, 1), +); + +for (var i = 0; i < 4000; i++) { + final x = rng.nextDouble() * 64; + final z = rng.nextDouble() * 64; + final y = noise.getNoise2(x, z) * 6.0; // same field as the terrain + final transform = vm.Matrix4.translation(vm.Vector3(x, y, z)) + ..rotateY(rng.nextDouble() * math.pi * 2); + scatter.addInstance(transform); // the matrix is cloned; mutating it later is safe +} + +// InstancedMesh rides on a component, not Node(mesh:). +final node = Node()..addComponent(InstancedMeshComponent(scatter)); +scene.add(node); +``` + +`addInstance(matrix, {color})` returns an index; edit later with `setInstanceTransform(i, m)` or move the whole batch at once through `updateInstanceTransforms((list) { ... })`. Per-instance `color` is a linear RGBA multiplier. Keep instance edits orientation-preserving, a mirrored (negative-determinant) instance edited with `updateInstanceTransforms(recomputeWinding: false)` renders inside-out. + +## The web noise trap + +The Dart `FastNoiseLite` relies on 32-bit integer math. On the web (dart2js) a Dart `int` is a JavaScript double, exact only to 53 bits, so the hash loses its low bits and 3D noise can overflow, producing wrong values. This is silent, you get a plausible-looking but incorrect field, and only on web. + +For web targets: + +- Prefer the **GLSL side** (`#include ` in a `.fmat` block), which is correct on every backend including WebGL2 and matches the Dart algorithms table-for-table. +- Or **bake** the field once with `bakeNoiseTexture(noise, width: ..., height: ...)` at build time or in a native isolate, then sample the texture. `bakeNoisePixels` is pure CPU with no engine imports, so it runs in a build hook or background isolate. + +Native platforms are unaffected. `noiseHash2`/`noiseHash3` are the bit-exact CPU/GPU-agreeing integer path for decisions that must never disagree (world generation, placement), but they carry the same web-overflow caveat, so make the decision once and share it rather than re-deriving it on both sides. + +## More depth + +`references/procedural.md` has the full `GeometryBuilder` and `MeshData` API (including off-isolate meshing), the complete `FastNoiseLite` config reference, natural rock, ocean, tree, sky, and island formation recipes, the instancing API in full, modular-kit assembly from the built-in primitives, and the web-noise caveat expanded. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/references/procedural.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/references/procedural.md new file mode 100644 index 0000000..ee68638 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/references/procedural.md @@ -0,0 +1,499 @@ +# Procedural content, the full API + +Everything for building flutter_scene content from code, custom meshes, noise, instancing, and modular kits. All symbols verified against the package source. Import geometry and instancing from the main barrel, noise from its own barrel: + +```dart +import 'package:flutter_scene/scene.dart'; +import 'package:flutter_scene/noise.dart'; +import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64 +``` + +--- + +## GeometryBuilder + +The incremental way to build a custom triangle mesh. You add vertices one at a time, each carrying whatever attributes are currently set, then reference them by returned index to form triangles. + +```dart +class GeometryBuilder { + GeometryBuilder({bool deduplicate = true}); + + // Sticky attribute setters (return `this`, so cascade or chain them). + GeometryBuilder normal(vm.Vector3 value); + GeometryBuilder texCoord(vm.Vector2 value); + GeometryBuilder texCoord1(vm.Vector2 value); // secondary UV set + GeometryBuilder color(vm.Vector4 value); // linear RGBA + GeometryBuilder tangent(vm.Vector4 value); // xyz + handedness in w + + int addVertex(vm.Vector3 position); // returns the vertex index + GeometryBuilder addTriangle(int a, int b, int c); // throws RangeError on a bad index + + int get vertexCount; + int get triangleCount; + + Uint8List packVertices(); // pure, no GPU context needed + MeshGeometry build({ + GeometryStorage storage = GeometryStorage.fixed, + GeometryBufferArena? bufferArena, + bool retainCpuData = true, + }); +} +``` + +### The sticky-attribute model + +The attribute setters do not apply to one vertex, they set state that every following `addVertex` inherits until you change it. This makes flat-shaded faces and per-region colors natural: + +```dart +final geometry = (GeometryBuilder() + ..color(vm.Vector4(1, 0, 0, 1)) // every vertex below is red... + ..addVertex(vm.Vector3(0, 0, 0)) + ..addVertex(vm.Vector3(1, 0, 0)) + ..color(vm.Vector4(0, 1, 0, 1)) // ...until this changes it to green + ..addVertex(vm.Vector3(0, 1, 0)) + ..addTriangle(0, 1, 2)) + .build(); +``` + +### Normals, generated or authored + +If you never call `normal()`, the builder generates area-weighted vertex normals from the faces you wound. That is the right default for most procedural geometry, terrain, extrusions, anything where the surface shape defines the normal. + +**Calling `normal()` even once opts the whole mesh out of generated normals.** After that, any vertex you add without an explicit normal keeps the default `(0, 0, 1)`, which is almost never what you want. So either author a normal for every vertex, or author none and let generation run. Do not mix. + +### Deduplication + +With `deduplicate: true` (the default), `addVertex` merges a vertex equal to one already added and returns the existing index, so a shared grid corner is stored once. Pass `deduplicate: false` when you want every call to produce a distinct vertex (flat shading with per-face normals, or per-vertex data that must not collapse). + +### Winding + +flutter_scene's front faces wind **counter-clockwise in model space**, matching glTF and standard conventions. For a surface that should face +Y (a heightmap, a floor), match the built-in plane's winding: for a cell with corners `v00`(x,z), `v10`(x+1,z), `v01`(x,z+1), `v11`(x+1,z+1), emit `addTriangle(v00, v01, v10)` and `addTriangle(v10, v01, v11)`. + +If a mesh renders inside-out (invisible from the front, visible and inverted-lit from behind), reverse each triangle's index order. Because generated normals follow the winding, fixing the winding fixes the normals too. This freedom is only for geometry you author. Never apply a per-triangle winding flip to an imported model to correct its orientation, that leaves its normals and image-based lighting wrong. + +### From builder to scene + +`build()` returns a `MeshGeometry`, which is a `Geometry`. Wrap it in a `Mesh` with a material, hang the mesh on a `Node`, add the node: + +```dart +final node = Node(mesh: Mesh(geometry, PhysicallyBasedMaterial())); +scene.add(node); +``` + +--- + +## MeshGeometry.fromArrays, the bulk path + +When you already have attributes as flat arrays (a generator that fills typed lists), skip the per-vertex calls and hand `MeshGeometry.fromArrays` structure-of-arrays data directly. Same result, less overhead for large meshes. + +```dart +MeshGeometry.fromArrays({ + required Float32List positions, // 3 floats/vertex, required + Float32List? normals, // 3/vertex; omitted -> generated for triangle lists + Float32List? texCoords, // 2/vertex; omitted -> (0, 0) + Float32List? texCoords1, // 2/vertex + Float32List? colors, // 4/vertex; omitted -> opaque white + Float32List? tangents, // 4/vertex + List? indices, // omitted -> vertex count must be a multiple of 3 + gpu.PrimitiveType primitiveType = gpu.PrimitiveType.triangle, + Aabb3? bounds, // skips the position scan; MUST cover every vertex + GeometryStorage storage = GeometryStorage.fixed, + GeometryBufferArena? bufferArena, + bool retainCpuData = true, +}); +``` + +Notes that bite: + +- Every supplied optional array must match the vertex count implied by `positions`. +- Out-of-range `indices` are **not** validated here (unlike `GeometryBuilder.addTriangle`), and produce stray triangles or holes silently. Keep every index in `0..vertexCount-1`. +- A `bounds` you pass that does not enclose every vertex makes the mesh over-cull and pop out of view at some angles. Omit it (the constructor scans positions) unless you computed it correctly off-thread. +- `retainCpuData: false` drops the CPU copy after upload, saving memory, but then the mesh cannot be raycast or read back with `extractMeshData`. + +### Updatable geometry (animated meshes) + +Pass `storage: GeometryStorage.updatable` to get a mesh you can mutate in place each frame without reallocating. The in-place updaters replace one attribute when the vertex count is unchanged: + +```dart +final water = MeshGeometry.fromArrays(positions: p, storage: GeometryStorage.updatable); +// later, per frame: +water.updatePositions(newPositions); // also updateNormals/TexCoords/Colors/Tangents +// or replace everything (may change the count): +water.rebuild(positions: p2, indices: i2); +``` + +An updatable mesh fixes its indexed-or-not state at construction, if you built it with `indices`, `rebuild` requires them thereafter, and vice versa. To start empty and fill later, pass a zero-length `positions` with `updatable`. Updatable geometry must retain CPU data and cannot use a buffer arena. + +`GeometryBufferArena({int blockSizeInBytes = 16 * 1024 * 1024})` lets many fixed meshes share immutable GPU buffer blocks, worth it when you build a large number of small static meshes. + +--- + +## MeshData, meshing off the render isolate + +Heavy generation (remeshing a voxel chunk, a large marching-cubes surface) should not block the render isolate. `MeshData` is a pure, isolate-transferable snapshot, build it on a background isolate with `compute`, send it back, upload it there. + +```dart +factory MeshData.build({ + required Float32List positions, + Float32List? normals, // omitted -> generated for triangle lists (the win here) + Float32List? texCoords, + Float32List? texCoords1, + Float32List? colors, + Float32List? tangents, + List? indices, + gpu.PrimitiveType primitiveType = gpu.PrimitiveType.triangle, + Map customAttributes = const {}, +}); +``` + +Recipe: + +```dart +// Top-level or static, runs on the background isolate. +MeshData buildChunk(ChunkInput input) { + final positions = /* your generator */; + final indices = /* ... */; + return MeshData.build(positions: positions, indices: indices); +} + +// On the render isolate: +final data = await compute(buildChunk, input); +final geometry = MeshGeometry.fromMeshData(data); +// or, to feed an existing updatable mesh in place: +existing.applyMeshData(data); +``` + +The normal generation is the expensive part, and running it inside `MeshData.build` is exactly the work you moved off the render isolate. + +Pure derivations on a `MeshData` (all off-isolate safe): `transformed(Matrix4)` (moves positions, carries normals by the inverse transpose so a non-uniform scale stays correct, reverses winding on a mirror), `unweld({attributes})`, `extractEdges({creaseAngleDegrees})`, static `MeshData.merge(parts)`, plus `triangleCount`/`triangles`. `Geometry.extractMeshData()` reads a loaded mesh back into one. + +--- + +## FastNoiseLite + +One configurable object evaluating several noise algorithms, sampled with `getNoise2`/`getNoise3`. Output is roughly in `[-1, 1]`. + +```dart +final noise = FastNoiseLite(seed: 1337) + ..frequency = 0.01 // coords are multiplied by this before eval + ..noiseType = NoiseType.openSimplex2 + ..fractalType = FractalType.fbm + ..octaves = 5; + +final h = noise.getNoise2(x, z); // 2D +final d = noise.getNoise3(x, y, z); // 3D +``` + +### Config reference + +| Field | Default | Meaning | +| --- | --- | --- | +| `seed` | 1337 | Seed for every noise type. | +| `frequency` | 0.01 | Input coordinates are scaled by this. Bigger = finer features. | +| `noiseType` | `openSimplex2` | Base algorithm (see below). | +| `fractalType` | `none` | How octaves layer (see below). | +| `octaves` | 3 | Number of fractal layers. More detail, more cost. | +| `lacunarity` | 2.0 | Frequency multiplier between octaves. | +| `gain` | 0.5 | Amplitude multiplier between octaves. | +| `weightedStrength` | 0.0 | Biases octave amplitude toward stronger detail. | +| `pingPongStrength` | 2.0 | Warp strength for `FractalType.pingPong`. | +| `cellularDistanceFunction` | `euclideanSq` | Distance metric for `NoiseType.cellular`. | +| `cellularReturnType` | `distance` | What cellular returns. | +| `cellularJitterModifier` | 1.0 | Cell-point jitter; above 1 causes artifacts. | +| `domainWarpType` | `openSimplex2` | Warp algorithm for `domainWarp2`/`domainWarp3`. | +| `domainWarpAmp` | 1.0 | Max warp distance. | +| `domainWarpFractalType` | `none` | Octave layering for domain warp. | + +Enums: + +- `NoiseType` = `openSimplex2` | `openSimplex2S` | `cellular` | `perlin` | `value`. +- `FractalType` = `none` | `fbm` (classic layered fractal, the usual terrain choice) | `ridged` (sharp ridges, mountains) | `pingPong`. +- `CellularDistanceFunction` = `euclidean` | `euclideanSq` | `manhattan` | `hybrid`. +- `CellularReturnType` = `cellValue` | `distance` | `distance2` | `distance2Add` | `distance2Sub` | `distance2Mul` | `distance2Div`. +- `DomainWarpType` = `openSimplex2` | `openSimplex2Reduced` | `basicGrid`. +- `DomainWarpFractalType` = `none` | `progressive` | `independent`. + +### Domain warp + +`domainWarp2`/`domainWarp3` distort the input coordinates before sampling, breaking up the regular look of raw fractal noise. The reference version mutates in place, this port returns the warped position for you to feed back in: + +```dart +final w = noise.domainWarp2(x, z); // ({double x, double y}) +final v = noise.getNoise2(w.x, w.y); +``` + +### Curl noise + +`noiseCurl3(x, y, z, {int seed = 1337, double epsilon = 0.25})` returns a divergence-free 3D vector `({x, y, z})` from a seeded potential field, for advecting particles so they swirl without clumping. Coordinates are taken pre-scaled (no frequency parameter), matching the GLSL `NoiseCurl3`. Advect by adding `curl * speed * dt`. A smaller `epsilon` sharpens the field and amplifies CPU/GPU divergence. + +### Baking noise to a texture + +Sampling many octaves per fragment is expensive. When the field is static, bake it once and sample the texture instead: + +```dart +Texture2D bakeNoiseTexture( + FastNoiseLite noise, { + required int width, + required int height, + double originX = 0.0, + double originY = 0.0, + double cellSize = 1.0, + TextureSampling sampling = const TextureSampling(), +}); +``` + +It bakes `getNoise2` over a `width` x `height` grid into a grayscale `Texture2D` (content is linear `data`, so mipmaps average cleanly) ready to bind as a material sampler. It must run where GPU resources are created (the raster thread). The CPU half, `bakeNoisePixels(noise, {width, height, originX, originY, cellSize})`, returns `Uint8List` RGBA and has no engine imports, so it runs in a build hook or a background isolate, then `Texture2D.fromPixels` uploads the result. + +--- + +### Natural formations: rocks, cliffs, trails, and scatter recipes + +Composing raw noise into realistic natural terrain and geology requires specific math patterns to avoid telltale procedural artifacts. + +### 1. Free-end Worley rock cracks (avoiding closed cell loops) + +Standard cellular Worley distance (`F2 - F1`) creates a continuous polygon network like bathroom tile or dry mud. To create natural weathering cracks with free ends, mask the cell borders with a low-frequency macro patch and a high-frequency grain breaker: + +```glsl +// GLSL shader bake or .fmat surface +// Cellular Worley noise returning F2 - F1 distance +float cwl = NoiseCellular2(p * 3.0, 1337, kNoiseCellularEuclidean, kNoiseCellularDistance2Sub, 1.0); +float net = smoothstep(0.08, -0.80, cwl); +float region = smoothstep(-0.2, 0.4, NoiseFbm2(p * 1.0, 1338, 3, 2.0, 0.5)); +float breaker = smoothstep(-0.4, 0.2, NoiseFbm2(p * 6.0, 1339, 2, 2.0, 0.5)); +float crack = net * region * breaker; // produces isolated segments with natural start/end points +``` + +### 2. Noise-modulated pitting (avoiding regular dot lattices) + +Thresholding Worley noise at a constant radius places a pit in every single cell, creating an artificial grid lattice. Modulate the threshold radius with an underlying Perlin field so pores vary in size and only appear in exposed weathering pockets: + +```glsl +float sizeVar = (NoiseFbm2(p * 2.5, 1337, 3, 2.0, 0.5) + 1.0) * 0.5; +float pw = NoiseCellular2(p * 5.0, 1338, kNoiseCellularEuclidean, kNoiseCellularDistance, 1.0); +float pit = smoothstep(0.05 + 0.24 * sizeVar * sizeVar, 0.005, pw) + * smoothstep(-0.1, 0.5, NoiseFbm2(p * 1.5, 1339, 3, 2.0, 0.5)); +``` + +### 3. Incised trail heightfields (scours vs flat stripes) + +Footpaths are formed by water and foot traffic compressing and eroding soil downwards. Sample the path polyline once (`trail.sample(n, evenlySpaced: true)`) and compute the minimum point-to-segment distance to cut the path profile into the terrain heightfield with raised spoil banks: + +```dart +double computeTerrainHeight(double x, double z, FastNoiseLite noise, List trailPoints) { + final baseHeight = noise.getNoise2(x, z) * 8.0; + + // Find minimum distance from (x, z) to the sampled 2D path segments + var minDist = double.infinity; + final p = vm.Vector2(x, z); + for (var i = 0; i < trailPoints.length - 1; i++) { + final a = vm.Vector2(trailPoints[i].x, trailPoints[i].z); + final b = vm.Vector2(trailPoints[i + 1].x, trailPoints[i + 1].z); + final ab = b - a; + final t = ((p - a).dot(ab) / ab.length2).clamp(0.0, 1.0); + final dist = (p - (a + ab * t)).length; + if (dist < minDist) minDist = dist; + } + + const pathWidth = 1.8; + const pathDepth = 0.45; + const bermHeight = 0.25; + + // Carve central path trough + final trench = (1.0 - (minDist / pathWidth).clamp(0.0, 1.0)) * pathDepth; + // Build gentle spoil berm along the verge + final verge = ((minDist - pathWidth * 0.8) / (pathWidth * 0.8)).clamp(0.0, 1.0); + final berm = math.sin(verge * math.pi) * bermHeight; + + return baseHeight - trench + berm; +} +``` + +### 4. Macro-massed pebble scatter (avoiding uniform sandpaper noise) + +Gravel and pebbles cluster into water-washed scour lines rather than spreading evenly over an entire level. Gate multi-scale pebble instances with a low-frequency macro massing field and key the hash off integer cell coordinates: + +```dart +void scatterPebbles(InstancedMesh finePebbles, InstancedMesh largeStones, FastNoiseLite terrainNoise) { + final macroNoise = FastNoiseLite(seed: 42)..frequency = 0.05; + const step = 0.8; + const cells = 80; // 64m / 0.8m + for (var ix = 0; ix < cells; ix++) { + for (var iz = 0; iz < cells; iz++) { + final x = ix * step; + final z = iz * step; + // Deterministic coordinate jitter keyed off integer cell indices + final h = noiseHash2(1337, ix, iz); + final jx = x + ((h & 0xFF) / 255.0 - 0.5) * 0.6; + final jz = z + (((h >> 8) & 0xFF) / 255.0 - 0.5) * 0.6; + + final mass = (macroNoise.getNoise2(jx, jz) + 1.0) * 0.5; + if (mass > 0.65) { + final y = terrainNoise.getNoise2(jx, jz) * 8.0; + final matrix = vm.Matrix4.translation(vm.Vector3(jx, y, jz)); + if (((h >> 16) & 0xFF) > 180) { + largeStones.addInstance(matrix); + } else { + finePebbles.addInstance(matrix); + } + } + } + } +} +``` + +### 5. Oceans and Gerstner waves + +Trochoidal Gerstner waves pull vertices horizontally toward wave peaks, creating sharp crests and wide flat troughs. Sum multiple directional waves and compute normals analytically: + +```glsl +// GLSL Gerstner wave displacement +struct Wave { vec2 dir; float amp; float freq; float speed; float steepness; }; + +// Caller seeds accumulators with tangent = vec3(1.0, 0.0, 0.0) and binormal = vec3(0.0, 0.0, 1.0). +vec3 evaluateGerstner(vec2 pos, float time, Wave w, float numWaves, inout vec3 tangent, inout vec3 binormal) { + vec2 d = normalize(w.dir); + float phase = dot(d, pos) * w.freq + time * w.speed; + float c = cos(phase); + float s = sin(phase); + float q = w.steepness / (w.amp * w.freq * numWaves); + + tangent += vec3(-q * d.x * d.x * w.amp * w.freq * s, + d.x * w.amp * w.freq * c, + -q * d.x * d.y * w.amp * w.freq * s); + binormal += vec3(-q * d.x * d.y * w.amp * w.freq * s, + d.y * w.amp * w.freq * c, + -q * d.y * d.y * w.amp * w.freq * s); + + return vec3(q * w.amp * d.x * c, + w.amp * s, + q * w.amp * d.y * c); +} +``` + +For shallow water transitions and shorelines: +- **Beer-Lambert Depth Extinction**: Declare `engine_inputs: [ depth ]` in the `.fmat` to sample linear opaque scene depth (`RenderInput.depth`). Compute water depth `d = sceneDepth - surfaceDepth` and attenuate color with `C = C_deep + (C_shallow - C_deep) * exp(-sigma_a * d)`. +- **Tidal Wet Sand**: Reduce sand roughness to 0.15 and multiply albedo by 0.6 within the wave wash zone to produce glistening wet shorelines. + +### 6. Trees, branching splines, and backlit foliage + +Trunk and branch structures follow Leonardo da Vinci's rule: total cross-sectional area is conserved across splits (d_parent^2 = sum d_child^2). Extrude branches along swept spline tubes using `TubeGeometry` (sweeping a round cross-section along a `ScenePath`) or `ExtrudeGeometry`: + +- **Backlit Leaf Translucency**: Set `Material.doubleSided = true` for two-sided rendering. In a custom leaf shader, add a diffuse transmission term so backlit foliage glows rather than rendering as a dark silhouette: +```glsl +// In custom leaf shader +float NdotL = dot(normal, lightDir); +float backLight = max(0.0, -NdotL) * leafTransmissionFactor; +vec3 litColor = albedo * (max(0.0, NdotL) + backLight * leafTranslucentColor); +``` +- **Quadratic Cantilever Wind**: Displace leaf and branch vertices in world space proportional to height squared (delta_p = windVec * (h / h_max)^2 * sin(omega * t - k * p)) so tips sway vigorously while roots remain anchored. + +### 7. Procedural skies and runtime IBL synchronization + +Use `PhysicalSkySource` (`lib/src/sky_sources.dart`) with analytic Rayleigh and Mie scattering. Assign `SkyEnvironment` to `Scene.skyEnvironment` or call `EnvironmentMap.fromSky` to bake prefiltered radiance and SH-9 diffuse coefficients into the scene's IBL automatically, and assign the source to `Scene.skybox` for matching background visuals. + +### 8. Islands, coastal bays, and sand dunes + +To form natural island topographies: +- **Domain-Warped Island Mask**: Multiply a radial distance falloff (1.0 - (r / R)^2) with domain-warped FBM to form organic bays, sandbars, and peninsulas rather than symmetrical circular cones. +- **Slope-Based Sediment Stripping**: Compute heightfield slope sqrt((dh/dx)^2 + (dh/dz)^2). Steep cliffs strip topsoil to expose rock strata, while gentle coastal planes accumulate golden beach sand. +- **Anisotropic Wind Dune Ripples**: Layer 8:1 anisotropically stretched noise perpendicular to the prevailing wind direction to generate fine ripple crests across sand surfaces. + +--- + +## The web noise caveat, expanded + +The Dart `FastNoiseLite` port relies on 32-bit integer arithmetic. On native platforms this is exact. On the web (dart2js), a Dart `int` is a JavaScript double, exact only to 53 bits, so the integer hash loses its low bits and 3D noise can overflow. The result is a plausible-looking but wrong field, silent, and web-only. A web-safe integer multiply for the Dart side is a planned follow-up. + +The GLSL half of the module is unaffected, it is correct on every backend including WebGL2, and implements the same algorithms with the same tables and seeds, so a field sampled on the CPU (native) and evaluated in a shader agree. The agreement has two tiers: + +- **Bit-exact**: `noiseHash2`/`noiseHash3` (and GLSL `NoiseHash2`/`NoiseHash3`) are pure integer math and match bit for bit across backends. Use them for decisions that must never disagree between machines (world generation, deterministic placement). +- **Float-close**: the float noise functions match within a small tolerance (float32 rounding differs per GPU), imperceptible visually. Do not re-derive a hard threshold from float noise on both the CPU and GPU sides, make the decision once and share the result. + +Both tiers carry the web-overflow caveat on the Dart side. Strategy by target: + +- **Native only**: use the Dart `FastNoiseLite` freely, on the render isolate or a background one. +- **Web, per-fragment noise**: move it to the GLSL side (`#include ` in a `.fmat` block). +- **Web, a static field**: bake it with `bakeNoiseTexture` (or `bakeNoisePixels` in a build hook / native isolate) and sample the texture. This sidesteps the overflow because the baking happens where `int` is 64-bit. + +--- + +## InstancedMesh, thousands of copies for one draw + +One geometry/material pair drawn many times, each placed by its own model transform. The whole set is one render item, one pipeline, one cull test. This is how you scatter foliage, crowds, debris, or a grid of the same prop without a node per copy. + +```dart +class InstancedMesh { + InstancedMesh({ + required Geometry geometry, + required Material material, + bool cullInstances = false, // per-instance cull after the aggregate pass + bool sortTransparentInstances = true, + }); + + int get instanceCount; + + int addInstance(vm.Matrix4 transform, {vm.Vector4? color}); // matrix is CLONED; returns index + void setInstanceTransform(int index, vm.Matrix4 transform); + void updateInstanceTransforms( + void Function(List transforms) update, { + bool recomputeWinding = true, + }); + void setInstanceColor(int index, vm.Vector4 color); // linear RGBA multiplier + void removeInstanceAt(int index); // shifts later indices down + void clearInstances(); +} +``` + +Attach it to a node with an `InstancedMeshComponent` (it does not go on `Node(mesh:)`): + +```dart +final mesh = InstancedMesh(geometry: geo, material: mat); +for (final placement in placements) { + mesh.addInstance(placement); // a Matrix4 in the instanced mesh's local space +} +final node = Node()..addComponent(InstancedMeshComponent(mesh)); +scene.add(node); +``` + +Practical notes: + +- `addInstance` clones the matrix, so reusing one scratch `Matrix4` across the loop is fine. +- The node the component is on transforms the entire batch. Instance transforms compose under it. +- To animate all instances cheaply, use `updateInstanceTransforms`, which invalidates the batch once instead of per call. Mutate the matrices in the callback list; do not add, remove, or replace entries. +- `updateInstanceTransforms(recomputeWinding: false)` skips the parity refresh. Only pass it when no edit changes a transform's winding. A mirrored (negative-determinant) edit under it renders those instances inside-out. +- `cullInstances: true` pays for per-instance culling, worth it for a large spatial spread whose instances enter view at different times; leave it off for a small compact clump that the single aggregate cull already handles. +- Set `cullInstances` per instanced mesh based on that trade; it is not a global. + +--- + +## Modular kits from the built-in primitives + +Before authoring a mesh, remember the ten primitives assemble a surprising amount by composition, no builder needed. Each is a `Geometry`, so each goes on its own `Node`, and a parent node groups a kit piece you can clone and place. + +| Class | Constructor | Notes | +| --- | --- | --- | +| `CuboidGeometry` | `CuboidGeometry(vm.Vector3 extents)` | Box from `-extents/2` to `+extents/2`. Positional. | +| `SphereGeometry` | `SphereGeometry({radius = 0.5, segments = 32, rings = 16})` | UV sphere. | +| `IcosphereGeometry` | `IcosphereGeometry({radius = 0.5, subdivisions = 2})` | Even triangle distribution. | +| `CylinderGeometry` | `CylinderGeometry({bottomRadius = 0.5, topRadius = 0.5, height = 1.0, ...})` | `topRadius: 0` makes a cone; different radii make a frustum. | +| `CapsuleGeometry` | `CapsuleGeometry({radius = 0.5, height = 1.0, ...})` | `height` is the mid-section; total Y is `height + 2*radius`. | +| `TorusGeometry` | `TorusGeometry({radius = 0.5, tubeRadius = 0.2, ...})` | Lies in XZ. | +| `PlaneGeometry` | `PlaneGeometry({width = 1.0, depth = 1.0, segmentsX = 1, segmentsZ = 1})` | XZ plane, faces +Y. | +| `DiscGeometry` | `DiscGeometry({radius = 0.5, segments = 32})` | Filled circle, XZ, faces +Y. | +| `RingGeometry` | `RingGeometry({innerRadius = 0.25, outerRadius = 0.5, segments = 32})` | Annulus, XZ, +Y. | +| `WedgeGeometry` | `WedgeGeometry(vm.Vector3 size)` | Triangular prism; base on `y = 0` (not Y-centered). | + +Because a cone is just `CylinderGeometry(topRadius: 0)`, a tree is a green cone on a brown cylinder, a fence is repeated thin cuboids, a table is a plane on four cylinders. Assemble each piece as a parented `Node` subtree, then `clone()` and place it, or feed the placements to an `InstancedMesh` when the same piece repeats many times. + +Every primitive except `PlaneGeometry` exposes a `Shape get collisionShape` for the physics package, so a code-built kit gets colliders for free. + +### Swept geometry for shapes primitives cannot make + +For paths, tubes, and profiles, sweep a `ScenePath` (`BezierPath`, `CatmullRomPath`, `PolylinePath`): + +- `TubeGeometry(path, {radius = 0.5, radialSegments = 12, stations = 64, caps = true})` for pipes, cables, vines. +- `ExtrudeGeometry(path, {required List profile, stations = 64, caps = true})` sweeps a 2D profile along the path (railings, moldings, extruded logos). +- `RibbonGeometry(path, {width = 1.0, stations = 64, alignment = RibbonAlignment.ground})` for flat strips (roads, trails). + +These build detailed shapes from a curve and a few parameters, often replacing an imported model outright. diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/SKILL.md new file mode 100644 index 0000000..f553708 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/SKILL.md @@ -0,0 +1,65 @@ +--- +name: flutter_scene-verification-loop +version: 2 +description: Close the visual-iteration loop when building or debugging a flutter_scene 3D app so you see your own output and self-correct. Use whenever a change affects what renders (geometry, materials, lighting, shaders, post-processing) or a frame looks wrong (black, washed-out, see-through, missing geometry). +--- + +# Verifying flutter_scene visually + +flutter_scene renders 3D. A rendering change you cannot see is a guess, and guessing at pixels is the single biggest waste of iterations. The highest-value habit is a closed visual loop plus judgment that does not drift. This skill is that loop. + +**The one thing to internalize: run it, let it settle, look at the frame AND the console, localize before you edit, repeat.** Do not change code off a hypothesis you did not confirm from the actual output. + +## The loop + +1. **Run.** Launch the app with `flutter run --enable-flutter-gpu` (native; the flag is mandatory and it is the only run flag, see the idioms skill). Where the editor MCP is connected, `run_project` launches the managed session instead. +2. **Settle.** A live frame is a moving target. Auto-exposure is still ramping, particles have a random phase, an animation is mid-clip, IBL re-bakes after the first present. Let the scene reach steady state (a few frames, or until the image stops changing) before you trust a capture. Do not screenshot the first frame and reason from it. +3. **Capture the frame AND read the console.** A screenshot alone hides errors that print; the console alone hides wrong pixels. Take both every time. Baseline path is a screenshot plus the run log; with the editor MCP it is `screenshot_viewport` plus `get_console`. +4. **Localize before editing.** When something is wrong, find where it goes wrong before you touch code. Read an intermediate buffer, read a single pixel's exact value, or scan for non-finite values. A NaN or Inf propagates silently into black or garbage downstream, so the first pass that produced it is the culprit, not the pass where you see the black. `references/loop.md` has the tool table and a symptom to action map. +5. **Correct, repeat.** Make one change, run the loop again. One change per iteration keeps cause and effect legible. + +## The readiness gate (do not debug through it) + +Rendering is gated on `Scene.initializeStaticResources()`. Until that Future completes, every frame is skipped and the engine prints exactly: + +``` +Flutter Scene is not ready to render. Skipping frame. +``` + +If you see that line, the scene is not broken, it is not ready. Wait for readiness (build geometry and materials inside `initializeStaticResources().then(...)`, gate the widget on `Scene.isReadyToRender`) before you diagnose anything else. A black frame while that line prints is the gate, not your code. + +## Judge blind, never self-score (the load-bearing rule) + +When deciding whether a change improved the look, **do not assign the frame a quality score.** Self-assigned scores drift upward, because the model is grading its own trajectory and wants to have made progress. That drift is how a session convinces itself a regression is an improvement. + +Instead, **compare two frames and return a binary pick.** Put the new frame next to a reference (a known-good target) or the previous frame, and answer only "which of these two is better", A or B. No number, no "8/10", no "looks pretty good now". A blind pairwise pick does not inflate the way a solo score does. This applies to every visual review, including the ones that feel obvious. + +If you have no reference at all, say so and describe the concrete difference between the two frames (this one is brighter here, that one has an artifact there) rather than inventing a score. + +## Empirical verification rules + +These rules prevent false diagnoses, hollow passes, and measurement traps during visual iteration: + +1. **Liveness before ablation.** A negative result is evidence only if the removed or modified term was actually live in the draw pass. Diff pixels for liveness before trusting an ablation. +2. **Tools must fail loud on empty measurements.** A tool that measures an empty population, zero pixels, or non-finite data must fail loud rather than returning a default passing number. +3. **Population discipline.** Always quote the population window, crop rectangle, brightness threshold, and rendering resolution beside any color or lighting figure. +4. **Baselines expire quickly.** Two captures taken hours apart in a changing tree reflect multiple edits; isolate paired A/B captures with temporary snapshots or git worktrees. +5. **Look at the raw frame before quoting numbers.** Inspect the actual captured frame before taking numbers off it; metrics can yield valid-looking numbers on corrupt frames. +6. **Negative control requirement.** A metric that returns the same score for positive and negative control populations cannot serve as evidence for either. +7. **Explanations are hypotheses, not evidence.** Before adopting an explanatory mechanism, identify the specific numerical observation that would differ if the mechanism were false. +8. **Attribution must reach the triangle.** When diagnosing geometry defects, trace down to the specific triangle indices and edge lengths rather than stopping at the mesh component. +9. **Symmetric domain clamping.** Clamping parametric lookup domains must be verified at both boundaries, and derived slope or heading accessors must clamp both sample points. +10. **Multi-band octave tables for tiling.** Two-band ratios like `hf/lf` are blind to regular mid-frequency patterns; inspect multi-octave energy tables to detect periodic tiling. +11. **Physical discrimination over naming.** A class-discriminating physical observation (such as missing shadow terminators) outranks code comments or variable names. +12. **Grazing light terminator crossing.** Under low-angle grazing lighting, prioritize terminator-crossing fractions over slope RMS to detect normal map over-amplification. +13. **Resolution scaling.** High-frequency energy (`hf/lf`) and relative contrast scale with resolution; compare them only at equal pixel resolutions. +14. **Paired capture isolation.** Snapshot source files or freeze environment state when capturing before/after pairs so background modifications cannot corrupt the comparison. + +## Be honest about tooling + +The richest observation tooling lives behind the editor MCP (`flutter_scene_mcp`): screenshots, console, NaN scans, render-graph capture, per-pass and per-pixel readback, viewport debug modes. A general-purpose observation server for an arbitrary running game is still being built, so do not assume those tools exist for every project. When the MCP is not connected, the baseline loop is still fully usable: `flutter run --enable-flutter-gpu`, read the console, take a screenshot. Do not claim a capability you cannot reach in the current project. + +## More depth + +- `references/loop.md` for the full editor-MCP tool table, the symptom to action map (black frame, washed-out, see-through, missing geometry), and the settle details. +- The `flutter_scene-idioms` skill (`references/traps.md`) for the underlying mistakes each symptom points back to (wrong vertex layout, hand-rolled winding flip, transform-in-place, the blank-frame causes). diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/references/loop.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/references/loop.md new file mode 100644 index 0000000..2ea7278 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/references/loop.md @@ -0,0 +1,145 @@ +# The verification loop in detail + +The core loop, the readiness gate, and the blind-judgment rule are in `SKILL.md`. This file has the +tool table (what exists where), the settle details, and the symptom to action map. + +--- + +## Two tooling tiers + +### Baseline (any project, no MCP) + +This always works and needs nothing installed beyond the package setup. + +- Launch: `flutter run --enable-flutter-gpu` (native; add `-d chrome` for web). The flag is mandatory. +- Console: read the run log. The readiness line, `debugPrint` output, asserts, and the 0.22.0 + blank-frame diagnostic all land here. +- Frame: take a screenshot of the running app after it settles. + +That is the whole loop when there is no editor. Run, settle, screenshot, read the log, correct. + +### Editor MCP (`flutter_scene_mcp`, when connected) + +The editor exposes richer observation. Tool names below are exact. Do not assume they exist unless +the MCP is actually connected for the current project. + +| Tool | What it does | Reach for it when | +| --- | --- | --- | +| `run_project` | Launch the editor-managed Play session (a managed `flutter run`). | Starting a session under the editor. | +| `build_project` | Start the selected build config; output streams to the console. | You want a build without launching. | +| `stop_project` | Stop the running session. | Ending or restarting cleanly. | +| `hot_reload` | Hot reload the running debug session. | A Dart-only change, fastest turnaround. | +| `hot_restart` | Hot restart the session. | State or startup changed, or reload did not take. | +| `get_console` | The build/run console tail plus building/running flags. | EVERY iteration, paired with a screenshot. | +| `screenshot_viewport` | The viewport as a PNG, what the user sees. | EVERY iteration, paired with the console. | +| `describe_scene` | The scene-graph tree (ids, paths, names, component types). | Confirming a node/mesh is actually in the scene. | +| `scan_for_nans` | Capture a frame and scan every float render target for NaN/Inf in pass order. | A black or garbage frame with no error. Find where non-finite values start. | +| `capture_render_graph` | Capture the next frame's graph with thumbnails. | You need to see intermediate buffers. | +| `list_render_passes` | The executed passes in order with CPU timings and the buffer keys each read/wrote, plus target formats and sizes. No images. | Learning which pass owns which buffer, and the key names to read. | +| `get_pass_output` | Render one captured buffer (a key like `scene_color`, `linear_depth`) as a PNG. NaN paints magenta, Inf yellow, negative blue. | Eyeballing an intermediate buffer to see which stage broke. | +| `read_pass_pixel` | One pixel's exact float RGBA from a captured buffer, with NaN/Inf flags. | Confirming an exact value (is this really 0, or NaN, or negative). | +| `list_viewport_debug_modes` | The available debug outputs (final, HDR color, linear depth, normals, AO, shadow atlas, ...) and which is active. | Seeing what debug views exist. | +| `set_viewport_debug_mode` | Render one debug output full-viewport. Set `final` to restore. | Inspecting depth/normals/AO live, paired with `screenshot_viewport`. | + +Render-graph capture (`capture_render_graph`, `list_render_passes`, `get_pass_output`, +`read_pass_pixel`, `scan_for_nans`) is gated on `Scene.debugAllowRenderGraphCapture`. It is a debug +opt-in, so a release build or a scene that never armed it returns nothing. The editor arms it for you; +outside the editor, set `Scene.debugAllowRenderGraphCapture = true` and call +`Scene.captureRenderGraph(...)` directly. + +--- + +## Settle, do not seed + +Frames differ from run to run for benign reasons. That is normal, not a bug to eliminate. + +- **Auto-exposure** (`Scene.autoExposure`) ramps toward the target over `speedUp`/`speedDown` seconds, + so the first second is darker or brighter than the settled image. +- **Particles and trails** carry a random phase, so a `ParticleSystem` looks different every launch. +- **Animations** are mid-clip unless you seek them, so a screenshot lands on an arbitrary frame. +- **Image-based lighting** re-bakes after the first present on some paths, so reflections dim in for + a frame before they are correct. + +So let the scene settle before you trust a capture. Watch until the image stops changing, or advance +a fixed few frames, then screenshot. Judge the settled frame, not the first one. + +**Seeding is a different job.** Strict determinism (a fixed random seed, a pinned animation time, a +frozen exposure) is what you set up for pixel-exact regression comparison, where two runs must be +byte-identical. You do not need it for ordinary observation. For "does this change look right", settle +and look. Reserve the seeding work for when you are building a golden or diffing two runs at the pixel +level. + +--- + +## Symptom to action map + +Localize before editing. Each row says what to capture first and the mistakes it usually points back +to. The mistakes are detailed in the `flutter_scene-idioms` skill's `references/traps.md`; this map +routes a symptom to the right one. + +### Entirely black frame + +1. Read the console FIRST. If `Flutter Scene is not ready to render. Skipping frame.` is printing, it + is the readiness gate, not your scene. Wait for `Scene.initializeStaticResources()`. Stop here. +2. In 0.22.0 a frame that issues zero draws prints once in debug naming the likely cause (not ready, + empty region, no views, no visible meshes, or a layer mask matching nothing). Read that line. +3. If draws are happening but the image is black, `scan_for_nans`. A NaN or Inf anywhere upstream + collapses the final image to black, and the scan names the first offending pass. Then + `get_pass_output` on that pass's buffer (NaN shows magenta) to confirm. +4. Common non-NaN causes: a degenerate camera (target equals position, `up` parallel to the view + direction on a top-down camera, FOV passed in degrees not radians), `layerMask: 0`, an oversized + environment texture that failed to allocate on the device. See traps #23 and #16. + +### Washed-out, low-contrast, or too-bright color + +1. `screenshot_viewport` after settling, and check whether auto-exposure has finished ramping (a + too-bright first second is just the ramp). +2. If it persists, suspect a shader-output contract break. A custom `ShaderMaterial`/`PostEffect`/sky + shader must output linear HDR premultiplied by alpha. Tone-mapping or gamma-encoding in the shader + gets applied a second time by the resolve pass, giving exactly this washed-out look. See traps #37 + and the root `MATERIALS.md`. +3. Also check for a non-color texture bound as color (a normal or metallic-roughness map without the + right `TextureContent`), which reads wrong and distance-dependent. Trap #2. +4. Hand-packed vertex data at the wrong stride also washes out color (the color attribute lands at the + wrong offset). `describe_scene` plus trap #17. + +### See-through or inside-out faces + +1. `set_viewport_debug_mode` to normals (or `get_pass_output` on the normals buffer) and look at the + orientation. Inverted normals confirm a winding problem. +2. Cause is almost always clockwise hand-built triangles. flutter_scene front faces wind + COUNTER-CLOCKWISE (CCW) in model space, matching glTF and standard conventions. Ensure triangle + indices wind CCW around the outward face normal, or omit normals and let the constructor derive + them. NEVER fix orientation with a per-triangle winding flip on an imported model; that leaves + normals and IBL wrong. Traps #13 and #17. +3. For an imported model rendered mirrored, check you did not overwrite the runtime importer's + `scale(1, 1, -1)` handedness root. Trap #5. + +### Missing or popping geometry + +1. `describe_scene` to confirm the node is actually in the graph. If it is absent, it is a scene-build + bug, not a render bug. +2. If it is present but invisible, check the layer mask (`Node.layers` is a bitmask, NOT inherited, + and must match the view's `layerMask`; `layers = 2` means `1 << 1`, not "layer 2"). Trap #10. +3. If it appears and disappears with camera angle, the bounds do not cover the geometry (a + caller-supplied `bounds` or `setLocalBounds` that is too small, or a swapped primitive geometry on + an older version). Widen or omit the bounds. Traps #8 and #24. +4. A moved skinned mesh that will not move is the skinned-node transform being ignored by design; move + the skeleton root instead. Trap #4. + +### A value looks numerically wrong (not visually) + +Use `read_pass_pixel` on the relevant buffer to read the exact float RGBA at a coordinate, with NaN/Inf +flags. This settles "is this pixel actually 0.5, or is it NaN, or negative" without eyeballing a PNG +that the display remap has already clamped. + +--- + +## Judgment, restated + +The blind-pairwise rule from `SKILL.md` is the part most likely to be skipped, so it bears repeating +here. When you have a before and an after, put them side by side and pick the better one as a binary +A-or-B choice against a reference or the previous frame. Do not narrate a score. A solo score climbs +on its own because you are grading your own progress; a blind pick between two concrete frames does +not. Every visual review runs through a pairwise pick, including the ones that feel too obvious to +bother with. diff --git a/sanctification-tcg/flutter-scene-spike/.gitignore b/sanctification-tcg/flutter-scene-spike/.gitignore new file mode 100644 index 0000000..36905b4 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/sanctification-tcg/flutter-scene-spike/.metadata b/sanctification-tcg/flutter-scene-spike/.metadata new file mode 100644 index 0000000..7fdd725 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d3b14c876900e553bc736ca19295fc09e3853e8e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + - platform: web + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/sanctification-tcg/flutter-scene-spike/README.md b/sanctification-tcg/flutter-scene-spike/README.md new file mode 100644 index 0000000..28327c6 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/README.md @@ -0,0 +1,66 @@ +# Flutter Scene Card Spike + +Bounded comparison of Flutter Scene against the approved Three.js +`runtime-look-v4-2026-09-07` card renderer. + +## Pinned toolchain + +- Flutter 3.47.2 stable +- Dart 3.13.2 +- `flutter_scene` 0.23.0 + +The Flutter SDK used during the spike is installed at: + +```text +C:\Users\dakovale\development\flutter +``` + +## Scope + +- Procedural rounded card geometry +- David Paper + Holographic baseline +- Reveal-safe universal back +- Front, Grazing, Edge, and Back poses +- One-finger rotation, pinch zoom, flip, reset, and sweep +- Web execution over the local network + +The full Three.js laboratory is intentionally not being duplicated. + +## Run + +The validated release build can be served to the local network with: + +```powershell +python -m http.server 5180 --bind 0.0.0.0 --directory build\web +``` + +Open `http://10.0.0.193:5180/` from the current network. + +For Flutter debug iteration: + +```powershell +$flutter = "$HOME\development\flutter\bin\flutter.bat" +& $flutter run -d web-server --web-hostname 0.0.0.0 --web-port 5180 +``` + +For a native target after its platform tooling is installed: + +```powershell +& $flutter run --enable-flutter-gpu +``` + +Android-native validation is currently blocked because this machine does not +have Java, the Android SDK, or `adb` installed. The web comparison does not +depend on that tooling. + +## Validate + +```powershell +& $flutter analyze +& $flutter test +& $flutter build web +``` + +Source assets in `assets\` are immutable copies of the approved Three.js +fixture. Scene's initialized build hook compiles loose textures and `.fmat` +materials into `flutter_scene_generated\`. diff --git a/sanctification-tcg/flutter-scene-spike/analysis_options.yaml b/sanctification-tcg/flutter-scene-spike/analysis_options.yaml new file mode 100644 index 0000000..035aeda --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - web/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/sanctification-tcg/flutter-scene-spike/assets/card-back.png b/sanctification-tcg/flutter-scene-spike/assets/card-back.png new file mode 100644 index 0000000..caa4df8 Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/assets/card-back.png differ diff --git a/sanctification-tcg/flutter-scene-spike/assets/card_holographic.fmat b/sanctification-tcg/flutter-scene-spike/assets/card_holographic.fmat new file mode 100644 index 0000000..f3ee5bc --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/assets/card_holographic.fmat @@ -0,0 +1,79 @@ +material { + name: "SanctificationCardHolographic", + shading_model: physical, + blending: opaque, + culling: back, + parameters: [ + { type: sampler2d, name: artwork_texture, hint: default_white }, + { type: sampler2d, name: finish_mask_texture, hint: default_black }, + { type: vec3, name: light_position, default: [2.2, 2.5, 4.4] }, + { type: float, name: finish_strength, default: 0.6 }, + { type: float, name: roughness, default: 0.23 }, + { type: float, name: surface_detail, default: 0.14 }, + ], +} + +fragment { + vec3 Spectrum(float phase) { + return 0.52 + 0.48 * cos( + 6.2831853 * (phase + vec3(0.0, 0.33, 0.67)) + ); + } + + float Hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + void Surface(inout MaterialInputs material) { + vec2 uv = GetUV(0); + vec3 artwork = SRGBToLinear(texture(artwork_texture, uv).rgb); + float mask = texture(finish_mask_texture, uv).r; + vec3 normal = GetWorldNormal(); + vec3 view_direction = GetViewDirection(); + vec3 light_direction = normalize( + material_params.light_position - GetWorldPosition() + ); + + float paper = Hash(floor(uv * vec2(230.0, 322.0))); + artwork *= 0.985 + (paper - 0.5) * + material_params.surface_detail * 0.08; + + float view_phase = dot(view_direction, vec3(0.43, 0.24, 0.87)); + float light_phase = dot(light_direction, vec3(-0.31, 0.66, 0.68)); + float phase = uv.x * 1.7 + uv.y * 0.9 + + view_phase * 0.78 + light_phase * 0.34; + vec3 spectrum = Spectrum(phase); + float grazing = pow( + 1.0 - clamp(dot(normal, view_direction), 0.0, 1.0), + 0.72 + ); + float coating = clamp( + mask * material_params.finish_strength * + (0.34 + grazing * 1.28), + 0.0, + 0.92 + ); + vec3 spectral_ink = artwork * mix(vec3(0.88), spectrum, 0.72); + vec3 coated = mix(artwork, mix(artwork, spectral_ink, 0.48), coating); + coated += spectrum * coating * grazing * 0.12; + + material.base_color = vec4(coated, 1.0); + material.metallic = coating * 0.18; + material.roughness = clamp( + mix(max(material_params.roughness, 0.56), 0.22, coating), + kMinRoughness, + 1.0 + ); + material.normal = normal; + material.specular_weight = 1.0; + material.specular_color = vec3(1.0); + material.ior = 1.5; + material.clearcoat = coating * 0.58; + material.clearcoat_roughness = 0.24; + material.clearcoat_normal = normal; + material.iridescence = coating * 0.42; + material.iridescence_ior = 1.3; + material.iridescence_thickness = mix(180.0, 430.0, spectrum.r); + PrepareMaterial(material); + } +} diff --git a/sanctification-tcg/flutter-scene-spike/assets/david-finish-mask.png b/sanctification-tcg/flutter-scene-spike/assets/david-finish-mask.png new file mode 100644 index 0000000..0e92846 Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/assets/david-finish-mask.png differ diff --git a/sanctification-tcg/flutter-scene-spike/assets/david-front.png b/sanctification-tcg/flutter-scene-spike/assets/david-front.png new file mode 100644 index 0000000..9eb7cd0 Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/assets/david-front.png differ diff --git a/sanctification-tcg/flutter-scene-spike/flutter_scene_generated/.gitignore b/sanctification-tcg/flutter-scene-spike/flutter_scene_generated/.gitignore new file mode 100644 index 0000000..c1cf5c3 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/flutter_scene_generated/.gitignore @@ -0,0 +1,4 @@ +# Written by flutter_scene's build hook. Generated assets are tied to the +# Flutter engine that built them, so they are never committed. +* +!.gitignore diff --git a/sanctification-tcg/flutter-scene-spike/hook/build.dart b/sanctification-tcg/flutter-scene-spike/hook/build.dart new file mode 100644 index 0000000..d2b1dce --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/hook/build.dart @@ -0,0 +1,28 @@ +import 'package:flutter_scene/build_hooks.dart'; +import 'package:hooks/hooks.dart'; + +void main(List args) async { + await build(args, (input, output) async { +// flutter_scene:init:start + // Import .glb and .fscene sources under assets/, loadable by source path + // with loadScene (and hot-reloadable). A no-op when there are no scenes. + buildScenes(buildInput: input, buildOutput: output); + // Compile .fmat materials under assets/, loadable by source path with + // loadFmatMaterial (and hot-reloadable). A no-op when there are none. + await buildMaterials(buildInput: input, buildOutput: output); +// flutter_scene:init:end + buildTextures( + buildInput: input, + buildOutput: output, + textures: const [ + 'assets/david-front.png', + 'assets/david-finish-mask.png', + 'assets/card-back.png', + ], + contents: const { + 'assets/david-finish-mask.png': TextureContent.data, + }, + alignForCompression: true, + ); + }); +} diff --git a/sanctification-tcg/flutter-scene-spike/lib/card_geometry.dart b/sanctification-tcg/flutter-scene-spike/lib/card_geometry.dart new file mode 100644 index 0000000..c188ca1 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/lib/card_geometry.dart @@ -0,0 +1,117 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter_scene/scene.dart'; +import 'package:vector_math/vector_math.dart' as vm; + +const cardWidth = 2.8571428571; +const cardHeight = 4.0; +const cardThickness = 0.0181405896; +const cardCornerRadius = 0.1269841270; +const _cornerSegments = 12; + +List _roundedPerimeter() { + final points = []; + final halfWidth = cardWidth / 2; + final halfHeight = cardHeight / 2; + final centers = [ + vm.Vector2(halfWidth - cardCornerRadius, halfHeight - cardCornerRadius), + vm.Vector2(-halfWidth + cardCornerRadius, halfHeight - cardCornerRadius), + vm.Vector2(-halfWidth + cardCornerRadius, -halfHeight + cardCornerRadius), + vm.Vector2(halfWidth - cardCornerRadius, -halfHeight + cardCornerRadius), + ]; + + for (var corner = 0; corner < centers.length; corner++) { + final startAngle = corner * math.pi / 2; + for (var step = 0; step <= _cornerSegments; step++) { + final angle = startAngle + step / _cornerSegments * math.pi / 2; + points.add( + centers[corner] + + vm.Vector2(math.cos(angle), math.sin(angle)) * cardCornerRadius, + ); + } + } + return points; +} + +MeshGeometry buildCardFaceGeometry({ + required bool front, +}) { + final perimeter = _roundedPerimeter(); + final z = front ? -cardThickness / 2 : cardThickness / 2; + final positions = [0, 0, z]; + final normals = [0, 0, front ? -1 : 1]; + final texCoords = [0.5, 0.5]; + + for (final point in perimeter) { + positions.addAll([point.x, point.y, z]); + normals.addAll([0, 0, front ? -1 : 1]); + final u = front + ? point.x / cardWidth + 0.5 + : 0.5 - point.x / cardWidth; + texCoords.addAll([u, 0.5 - point.y / cardHeight]); + } + + final indices = []; + for (var index = 0; index < perimeter.length; index++) { + final current = index + 1; + final next = (index + 1) % perimeter.length + 1; + if (front) { + indices.addAll([0, next, current]); + } else { + indices.addAll([0, current, next]); + } + } + + return MeshGeometry.fromArrays( + positions: Float32List.fromList(positions), + normals: Float32List.fromList(normals), + texCoords: Float32List.fromList(texCoords), + indices: indices, + ); +} + +MeshGeometry buildCardEdgeGeometry() { + final perimeter = _roundedPerimeter(); + final positions = []; + final normals = []; + final indices = []; + + for (var index = 0; index < perimeter.length; index++) { + final current = perimeter[index]; + final next = perimeter[(index + 1) % perimeter.length]; + final normal = (current + next).normalized(); + final base = positions.length ~/ 3; + positions.addAll([ + current.x, + current.y, + -cardThickness / 2, + current.x, + current.y, + cardThickness / 2, + next.x, + next.y, + cardThickness / 2, + next.x, + next.y, + -cardThickness / 2, + ]); + for (var vertex = 0; vertex < 4; vertex++) { + normals.addAll([normal.x, normal.y, 0]); + } + indices.addAll([ + base, + base + 2, + base + 1, + base, + base + 3, + base + 2, + ]); + } + + return MeshGeometry.fromArrays( + positions: Float32List.fromList(positions), + normals: Float32List.fromList(normals), + indices: indices, + ); +} diff --git a/sanctification-tcg/flutter-scene-spike/lib/main.dart b/sanctification-tcg/flutter-scene-spike/lib/main.dart new file mode 100644 index 0000000..453be00 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/lib/main.dart @@ -0,0 +1,391 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_scene/scene.dart'; +import 'package:vector_math/vector_math.dart' as vm; + +import 'card_geometry.dart'; + +const spikeTitle = 'FLUTTER SCENE CARD SPIKE'; + +void main() { + runApp(const SceneSpikeApp()); +} + +class SceneSpikeApp extends StatelessWidget { + const SceneSpikeApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: CardSceneView(), + ); + } +} + +class CardSceneView extends StatefulWidget { + const CardSceneView({super.key}); + + @override + State createState() => _CardSceneViewState(); +} + +class _CardSceneViewState extends State { + static const _frontPitch = -0.06; + static const _frontYaw = 0.12; + static const _minCameraDistance = 5.0; + static const _maxCameraDistance = 12.0; + + final Scene scene = Scene(); + final Node cardRoot = Node(name: 'CARD_ROOT'); + final vm.Vector3 lightPosition = vm.Vector3(2.2, 2.5, 4.4); + + bool ready = false; + String? error; + double pitch = _frontPitch; + double yaw = _frontYaw; + double targetPitch = _frontPitch; + double targetYaw = _frontYaw; + double cameraDistance = 8.2; + double defaultCameraDistance = 8.2; + double gestureStartDistance = 8.2; + Offset lastFocalPoint = Offset.zero; + bool scriptedMotion = false; + Duration latestElapsed = Duration.zero; + double scriptStartSeconds = 0; + double scriptStartPitch = _frontPitch; + double scriptStartYaw = _frontYaw; + + @override + void initState() { + super.initState(); + _initializeScene(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final size = MediaQuery.sizeOf(context); + if (size.isEmpty) return; + final verticalTangent = math.tan(34 * math.pi / 360); + final verticalFit = cardHeight / 2 / verticalTangent; + final horizontalFit = + cardWidth / 2 / (verticalTangent * size.aspectRatio); + final nextDefault = math.max(8.2, math.max(verticalFit, horizontalFit) * 1.08); + if ((cameraDistance - defaultCameraDistance).abs() < 0.001) { + cameraDistance = nextDefault; + } + defaultCameraDistance = nextDefault; + } + + Future _initializeScene() async { + try { + debugPrint('Scene spike: initializing static resources'); + await Scene.initializeStaticResources(); + debugPrint('Scene spike: loading textures'); + final artwork = await loadTexture('assets/david-front.png'); + final finishMask = await loadTexture('assets/david-finish-mask.png'); + final backArtwork = await loadTexture('assets/card-back.png'); + debugPrint('Scene spike: loading materials'); + final frontMaterial = await loadFmatMaterial( + 'assets/card_holographic.fmat', + ); + final backMaterial = await loadFmatMaterial( + 'assets/card_holographic.fmat', + ); + + frontMaterial.parameters + ..setTexture( + 'artwork_texture', + artwork.sampledTexture!, + sampler: artwork.sampledSampler, + ) + ..setTexture( + 'finish_mask_texture', + finishMask.sampledTexture!, + sampler: finishMask.sampledSampler, + ) + ..setVec3('light_position', lightPosition) + ..setFloat('finish_strength', 0.6) + ..setFloat('roughness', 0.23) + ..setFloat('surface_detail', 0.14); + backMaterial.parameters + ..setTexture( + 'artwork_texture', + backArtwork.sampledTexture!, + sampler: backArtwork.sampledSampler, + ) + ..setTexture( + 'finish_mask_texture', + finishMask.sampledTexture!, + sampler: finishMask.sampledSampler, + ) + ..setVec3('light_position', lightPosition) + ..setFloat('finish_strength', 0) + ..setFloat('roughness', 0.42) + ..setFloat('surface_detail', 0); + + final edgeMaterial = PhysicallyBasedMaterial() + ..baseColorFactor = vm.Vector4(0.33, 0.27, 0.16, 1) + ..roughnessFactor = 0.62 + ..metallicFactor = 0.02; + + cardRoot.addAll([ + Node( + name: 'CARD_FRONT', + mesh: Mesh(buildCardFaceGeometry(front: true), frontMaterial), + ), + Node( + name: 'CARD_BACK', + mesh: Mesh(buildCardFaceGeometry(front: false), backMaterial), + ), + Node( + name: 'CARD_EDGE', + mesh: Mesh(buildCardEdgeGeometry(), edgeMaterial), + ), + ]); + scene.add(cardRoot); + scene.add( + Node( + name: 'STUDIO_LIGHT', + localTransform: vm.Matrix4.translation(lightPosition), + )..addComponent( + PointLightComponent( + PointLight( + color: vm.Vector3(1.0, 0.87, 0.63), + intensity: 42, + range: 20, + falloffExponent: 1.7, + ), + ), + ), + ); + scene.environmentSettings = EnvironmentSettings( + toneMapping: ToneMappingMode.aces, + exposure: 1, + ); + _applyCardRotation(); + debugPrint('Scene spike: ready'); + if (mounted) { + setState(() { + ready = true; + }); + } + } catch (exception) { + if (mounted) { + setState(() { + error = exception.toString(); + }); + } + } + } + + void _applyCardRotation() { + cardRoot.rotation = + vm.Quaternion.axisAngle(vm.Vector3(0, 1, 0), yaw) * + vm.Quaternion.axisAngle(vm.Vector3(1, 0, 0), pitch); + } + + void _setPose(double nextPitch, double nextYaw) { + scriptedMotion = false; + pitch = nextPitch; + yaw = nextYaw; + targetPitch = nextPitch; + targetYaw = nextYaw; + _applyCardRotation(); + setState(() {}); + } + + void _reset() { + scriptedMotion = false; + cameraDistance = defaultCameraDistance; + _setPose(_frontPitch, _frontYaw); + } + + void _flip() { + scriptedMotion = false; + targetPitch = pitch; + targetYaw = yaw + math.pi; + } + + void _toggleSweep(Duration elapsed) { + if (scriptedMotion) { + scriptedMotion = false; + targetPitch = pitch; + targetYaw = yaw; + } else { + scriptedMotion = true; + scriptStartSeconds = elapsed.inMicroseconds / 1000000; + scriptStartPitch = pitch; + scriptStartYaw = yaw; + } + setState(() {}); + } + + void _onScaleStart(ScaleStartDetails details) { + scriptedMotion = false; + lastFocalPoint = details.focalPoint; + gestureStartDistance = cameraDistance; + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + if (details.pointerCount >= 2) { + cameraDistance = (gestureStartDistance / details.scale).clamp( + _minCameraDistance, + _maxCameraDistance, + ); + setState(() {}); + return; + } + final delta = details.focalPoint - lastFocalPoint; + yaw += delta.dx * 0.008; + pitch = (pitch + delta.dy * 0.008).clamp(-1.25, 1.25); + targetPitch = pitch; + targetYaw = yaw; + lastFocalPoint = details.focalPoint; + _applyCardRotation(); + } + + void _tick(Duration elapsed, double deltaSeconds) { + latestElapsed = elapsed; + final frameDelta = deltaSeconds.clamp(0, 0.1); + if (scriptedMotion) { + final seconds = elapsed.inMicroseconds / 1000000; + final progress = ((seconds - scriptStartSeconds) / 5).clamp(0, 1); + final eased = progress * progress * (3 - 2 * progress); + final wave = math.sin(eased * math.pi); + pitch = scriptStartPitch - 0.12 * wave; + yaw = scriptStartYaw + math.pi / 7.5 * wave; + if (progress >= 1) { + scriptedMotion = false; + targetPitch = scriptStartPitch; + targetYaw = scriptStartYaw; + } + } else { + final damping = 1 - math.exp(-9 * frameDelta); + pitch += (targetPitch - pitch) * damping; + yaw += (targetYaw - yaw) * damping; + } + _applyCardRotation(); + } + + PerspectiveCamera _camera() { + return PerspectiveCamera( + position: vm.Vector3(0, 0, -cameraDistance), + target: vm.Vector3.zero(), + fovRadiansY: 34 * math.pi / 180, + fovNear: 0.1, + fovFar: 100, + ); + } + + Widget _controlButton(String label, VoidCallback onPressed) { + return FilledButton.tonal( + onPressed: onPressed, + style: FilledButton.styleFrom( + foregroundColor: const Color(0xFFF3EFE5), + backgroundColor: const Color(0xFF171B23), + side: const BorderSide(color: Color(0x554B5360)), + ), + child: Text(label), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF080A0E), + body: Stack( + children: [ + if (ready) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + child: SceneView( + scene, + cameraBuilder: (_) => _camera(), + onTick: _tick, + ), + ), + ) + else + Center( + child: error == null + ? const CircularProgressIndicator() + : Padding( + padding: const EdgeInsets.all(24), + child: Text( + error!, + style: const TextStyle(color: Colors.redAccent), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + spikeTitle, + style: TextStyle( + color: Color(0xFFD6C078), + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: 3), + const Text( + 'David · Paper · Holographic', + style: TextStyle( + color: Color(0xFFF3EFE5), + fontFamily: 'serif', + fontSize: 20, + ), + ), + const Spacer(), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _controlButton('Front', () { + _setPose(_frontPitch, _frontYaw); + }), + _controlButton('Grazing', () { + _setPose(-0.14, 52 * math.pi / 180); + }), + _controlButton('Edge', () { + _setPose(-0.05, math.pi / 2); + }), + _controlButton('Back', () { + _setPose(_frontPitch, math.pi + _frontYaw); + }), + _controlButton('Flip', _flip), + _controlButton('Reset', _reset), + _controlButton( + scriptedMotion ? 'Stop sweep' : 'Play sweep', + () => _toggleSweep(latestElapsed), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Drag to rotate · pinch to zoom', + style: TextStyle( + color: Color(0xFF9DA3AD), + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/sanctification-tcg/flutter-scene-spike/pubspec.lock b/sanctification-tcg/flutter-scene-spike/pubspec.lock new file mode 100644 index 0000000..08d0dd1 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/pubspec.lock @@ -0,0 +1,378 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + data_assets: + dependency: transitive + description: + name: data_assets + sha256: "8bfdbf25ec8a0f4b5a3c993042c4ab9996ba354f0b03d40f4e360f3730d71cae" + url: "https://pub.dev" + source: hosted + version: "0.20.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + flat_buffers: + dependency: transitive + description: + name: flat_buffers + sha256: "7c1de2d6eb5f3e61e5c50040841109f509deaaf2b12ec0d57b92456d9ea50345" + url: "https://pub.dev" + source: hosted + version: "25.9.23" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_gpu: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_gpu_shaders: + dependency: transitive + description: + name: flutter_gpu_shaders + sha256: "1e8c5438c24e79629f5688c7b5dfd2b7cf09b36b443a806bc8c083cd8c04644a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_scene: + dependency: "direct main" + description: + name: flutter_scene + sha256: "1cc32b5ed0d05296c1f7958e63168a750c05c2373d6769ad9b9eaa973b2f97fe" + url: "https://pub.dev" + source: hosted + version: "0.23.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: "direct main" + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + image: + dependency: transitive + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + scene: + dependency: transitive + description: + name: scene + sha256: f14dd23ea3858041b644ec4824eca5cae817b42aca03c5ed2efcf1afa003d554 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: "direct main" + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.13.2 <4.0.0" + flutter: ">=3.47.0" diff --git a/sanctification-tcg/flutter-scene-spike/pubspec.yaml b/sanctification-tcg/flutter-scene-spike/pubspec.yaml new file mode 100644 index 0000000..b390178 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/pubspec.yaml @@ -0,0 +1,89 @@ +name: flutter_scene_spike +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_scene: ^0.23.0 + vector_math: ^2.4.2 + hooks: ^2.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + assets: + - flutter_scene_generated/ + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/sanctification-tcg/flutter-scene-spike/test/configuration_test.dart b/sanctification-tcg/flutter-scene-spike/test/configuration_test.dart new file mode 100644 index 0000000..5e5df38 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/test/configuration_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_scene_spike/main.dart'; + +void main() { + test('uses the expected spike label', () { + expect(spikeTitle, 'FLUTTER SCENE CARD SPIKE'); + }); +} diff --git a/sanctification-tcg/flutter-scene-spike/web/favicon.png b/sanctification-tcg/flutter-scene-spike/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/web/favicon.png differ diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-192.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-192.png differ diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-512.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-512.png differ diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-192.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-192.png differ diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-512.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-512.png differ diff --git a/sanctification-tcg/flutter-scene-spike/web/index.html b/sanctification-tcg/flutter-scene-spike/web/index.html new file mode 100644 index 0000000..563b907 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + flutter_scene_spike + + + + + + + diff --git a/sanctification-tcg/flutter-scene-spike/web/manifest.json b/sanctification-tcg/flutter-scene-spike/web/manifest.json new file mode 100644 index 0000000..03b3d43 --- /dev/null +++ b/sanctification-tcg/flutter-scene-spike/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "flutter_scene_spike", + "short_name": "flutter_scene_spike", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/sanctification-tcg/functional-spec-v2.md b/sanctification-tcg/functional-spec-v2.md new file mode 100644 index 0000000..349b647 --- /dev/null +++ b/sanctification-tcg/functional-spec-v2.md @@ -0,0 +1,1097 @@ +# Christian Habit Tracker TCG - Functional Spec v2 + +I think a good name for the application would still be **Sanctification TCG**; as the objective is to grow in Christ and move towards holiness. + +Habit trackers are super common place and incredibly overdone. I find the same issue with every single one of them - tracking your habits needs to be a habit itself. Many applications try to add a "carrot" but nothing actually seems to provide a good sense to really reel you back in. + +On the opposite front, bad habits also tend to just be a number or a streak. If you fail, the app resets the number and that is about it. I want this to feel more human than that and I especially do not want failure to become a reason to avoid opening the app again. + +One thing that has become hugely popular is TCGs - more importantly card openings. This idea that you can collect the cards, try to obtain rare instances, collect wide variations of the same card, trade with friends, and just enjoy the experience of opening something you earned. + +All these card games are typically built in their own universe - but I want to do it Christian themed. + +The collection system is not really secondary to the habit tracker. It is the thing that makes the habit tracker unique. If I just wanted to track habits I could use pen and paper or download one of the thousand other habit trackers that already exist. The cards are the hook that should make me want to come back and keep using it. + +Below breaks this down into the larger functional pieces of the project, the things I think are already decided, and the unknowns that still need to be explored. + +--- + +## Product Principles + +### Core Purpose + +Ultimately this is a tool to help build essential habits with God. The collecting, trading, opening animations, social aspects, etc. are all there to make that process fun enough that someone actually wants to keep coming back. + +I think four concepts are equally important here: + +* Completion - actually doing the habits you said you wanted to do. +* Honesty - being truthful with yourself about whether you actually did them. +* Consistency - building something sustainable over time rather than obsessing over a perfect streak. +* Returning from failure - if you fall off for a day, week, month, or longer, the application should make you want to come back instead of making you feel like everything was lost. + +### Honor System + +How or whether someone actually is doing what they are tracking is between them and God - and with accountability partners if they choose to do that. + +What it means to complete a task is also between the user and God. If prayer for them means 5 minutes or 1 hour, that is fine. I think we should outline during onboarding that trying to say a 5 second prayer before entering the app is not really qualification. It is a matter of the heart - not trying to min/max the game. + +At the same time, the app should not try to grade the quality of someone's prayer, Bible reading, church attendance, etc. + +Five minutes of prayer should not give a worse pack than sixty minutes. A whole day of prayer should not give more rewards than an earnest five minutes. Habit completion is binary from the application's perspective. + +### Honesty vs. the Economy + +I don't think we should build this like an anti-cheat system. + +The rarity and economy should be modeled around the assumption that everyone earnestly completes every task and reaches the consistency targets available to them. That is basically the maximum expected supply. + +If people miss days or do not complete everything, the economy simply develops more slowly than that baseline. + +If someone lies and clicks every task anyway, that is technically just reaching the baseline we already modeled for. It is between them and God, and I do not want the app to turn into a verification system for prayer or Christian practice. + +Because of that, rewards should also never scale based on self-reported duration or difficulty. We should not create an incentive to claim that a five minute prayer was a two hour prayer because it gives better cards. + +### Privacy + +Habit tracking is private by default. + +This becomes especially important with negative habits because the user may be putting extremely sensitive information into the application. + +I want sensitive customer content to be encrypted in a way where the server cannot casually read it. Statistics can still exist where possible, but content such as a user's sins, struggles, custom habit names, notes, etc. should not just be plaintext sitting in the database for a server administrator to inspect. + +Exactly how we accomplish this is more of a technical-spec question. Client-side encryption and potentially some kind of BYOK design are worth exploring. The important functional requirement is that private content should be private even from us as much as reasonably possible. + +Accountability sharing should also be obscure by default. A user should be able to say something like: + +> Daniel needs your prayer today. + +without automatically telling the accountability partner exactly what happened. + +The user can explicitly choose to share more detail if they want to. + +--- + +## Christianity + +At the core foundation, what we consider Christianity for the purposes of this application is currently founded in the words and logic of the Nicene Creed. + +This is the standard **for now** and can be revisited later if needed. + +> We believe in one God the Father Almighty, Maker of heaven and earth, and of all things visible and invisible. +> +> And in one Lord Jesus Christ, the only-begotten Son of God, begotten of the Father before all worlds, God of God, Light of Light, Very God of Very God, begotten, not made, being of one substance with the Father by whom all things were made; who for us men, and for our salvation, came down from heaven, and was incarnate by the Holy Spirit of the Virgin Mary, and was made man, and was crucified also for us under Pontius Pilate. He suffered and was buried, and the third day he rose again according to the Scriptures, and ascended into heaven, and sitteth on the right hand of the Father. And he shall come again with glory to judge both the quick and the dead, whose kingdom shall have no end. +> +> And we believe in the Holy Spirit, the Lord and Giver of Life, who proceedeth from the Father, who with the Father and the Son together is worshiped and glorified, who spoke by the prophets. And we believe in one holy catholic and apostolic Church. We acknowledge one baptism for the remission of sins. And we look for the resurrection of the dead, and the life of the world to come. Amen. + +### Shared Core Plus Traditions + +Rather than treating Catholic, Orthodox, Protestant, etc. as completely separate decks, I think it makes more sense to have a shared Christian core and then tradition-specific collections around it. + +The shared core can contain things such as: + +* Scripture +* Biblical people +* Biblical places +* Biblical events +* Early church history +* Major ecumenical councils +* Core Christian doctrines +* Things generally shared within Nicene Christianity + +Tradition-specific collections can then include: + +* Catholic +* Orthodox +* Protestant +* Potentially more specific traditions later + +A user does **not** pick one tradition and get locked into it. A Catholic-specific card can be visible to and collected by an Orthodox or Protestant user, and vice versa. + +I want people to be able to collect every tradition because part of the point is also learning what other Christians actually believe and where traditions differ. + +Maybe the user can choose a UI/theme that matches their tradition at some point, but that would be visual and not limit the cards they can collect. + +### Controversies / Non-Nicene Movements + +I still want historical heresies and theological controversies represented because they are genuinely interesting and useful to learn about. I just don't think a generic "Heretic Deck" is the best structure. + +This can include things such as: + +* Arianism +* Gnosticism +* Pelagianism +* Nestorian controversies +* Mormonism / Latter-day Saint theology +* Jehovah's Witnesses +* Other non-Nicene or historically disputed movements + +The card should explain what the person/movement taught, why it became controversial, and how it differs from the Nicene standard we are using for the application. + +The card cannot contain all the information so we can also make sure to have a panel for more information or resource links to learn more about it. It can help explain the position, why it matters historically/theologically, and how it compares to the Nicene standard. They aren't insult cards. + +This should be educational rather than just putting a big "HERETIC" stamp on people and calling it a day. + +### The Trinity Card + +I no longer think The Father, The Son, and The Holy Spirit should be the highest collectible rarity tier. + +I do still really like the idea of the Trinity having a unique place in the application though. + +One idea is that during the tutorial - or immediately after skipping the tutorial - every user is given **The Trinity** as their first and permanent card. + +This card would be different from every other card in the system: + +* It is given, not earned. +* It cannot be traded. +* It cannot be destroyed. +* It is always considered mint condition. +* The user can customize the finish/material presentation however they like without affecting rarity or the economy. +* It does not participate in normal rarity or population mechanics. + +The symbolism I like here is that the most important thing in the collection is something you did not grind for or earn. It was given to you and you cannot lose it. + +The exact theological messaging obviously needs some thought because traditions differ on some of the implications there, but I think the overall meaning is good. + +**Initial verse thought:** John 3:16. It seems like a pretty decent fit for the idea of something given rather than earned. + +The artwork for this card also needs special consideration. I do not want us to accidentally make some weird literal depiction of the Father / Son / Holy Spirit just because we need card art. + +--- + +## Core Application Loop + +The rough loop is: + +1. Open the application. +2. See today's habits and current pack state. +3. Complete and report habits honestly. +4. Improve the day's reward / pack. +5. Open it now or save it for later. +6. Collect cards and variants. +7. Inspect, organize, learn from, and eventually trade cards. +8. Come back tomorrow. + +The exact pack reward model still needs to be worked through. + +--- + +## Habit Tracking + +Ultimately this is still a habit tracker at its base. + +### Positive Habit Tracking + +I think initially we will start with a couple of base good habits to build on: + +* Prayer +* Bible reading +* Weekly church attendance +* Study - something like a devotional, a chapter of Christian literature, digging into theology, etc. +* Volunteering ?* - I am still not sure how to track this cadence. It doesn't feel like a daily thing but it is also obviously a category that builds Christian values. + +Custom habits can come later or be included early depending on how much work it ends up being. + +You can log up to the day prior and still receive whatever reward that day qualified for. + +You can always log older days/weeks of progress toward habits for your own records and consistency statistics, but they should not generate retroactive packs indefinitely. + +### Consistency Instead of Streaks + +I don't think positive habits should revolve around streaks anymore. + +A streak makes one missed day disproportionately destructive. Going from 93 days to "0" is a terrible way to represent someone who completed something 93 out of the last 94 days. + +Consistency is a much stronger metric. + +We can track things such as: + +* 7-day consistency +* 30-day consistency +* 90-day consistency +* Lifetime completion rate +* Total completions +* Returning after time away + +The exact windows and which ones affect rewards are still subject to change. + +This also gives us more interesting achievement opportunities without making the user feel like one missed Tuesday erased three months of work. + +### Negative Habit Tracking + +I want there to be an option to track bad habits that someone may have. These can be custom input although we can provide some baseline categories/examples like lust, addiction, smoking, drinking, anger, etc. + +This tracks separately from the positive habits the user is trying to build. + +For negative habits, I think there are two useful measurements: + +#### Abstinence + +How long has it been since the behavior occurred? + +This can still naturally be represented as a streak because the elapsed time itself is meaningful here. + +#### Honesty / Engagement + +Is the user continuing to truthfully track, return, and work on the problem? + +This should **not** reset when someone fails. + +If someone falls after 100 days and reports it honestly, we should not turn that into: + +> You failed. Everything is gone. Start over. + +The abstinence count resets because that is simply factual, but the user's larger journey does not. + +### Recovery Milestones + +After a fall, I like the idea of recovery milestones: + +* First day back +* Three days back +* One week back +* Other meaningful points we decide on + +Every milestone should be celebrated, but **not with card packs or rarity rewards**. I do not want to accidentally create some weird side incentive where failure becomes part of an optimal reward strategy. + +The celebration can be messaging, visual acknowledgement, Scripture, encouragement, etc. + +### Failure Messaging + +Every fall needs to be met with grace and good messaging. + +The experience I want the morning after someone fails badly is basically: + +> We are glad you're back. You are still loved. Christ died for our sins. Repent, get back up, and keep moving forward. Every journey begins again with the first step. + +The exact wording will need theological/content review later, but that is the tone. + +The application should never make the user feel like they should avoid opening it because they are ashamed of what happened. + +### Voluntary Card Burning + +I still like the visual and ceremonial idea of burning a card. I just don't want it attached to mandatory punishment for honestly reporting a failure. + +Maybe card burning becomes voluntary. + +Possible uses: + +* A user voluntarily burns a card as part of some personal ceremony / reset. +* Burning duplicates becomes part of the card economy. +* Cards can be consumed in future crafting/trade-up systems. + +The exact implementation is still open. + +--- + +## TCG / Collection + +The whole premise of the "game" is collection. This is the carrot that makes the habits more interesting and hopefully gives the application a real community around it. + +There is no actual card combat system planned. The fun is collecting, opening, inspecting, organizing, trading, learning, and hunting for rare variants. + +### Collection Completion + +I still like the general idea that collecting one of every base card by yourself should take something like at least a year of very consistent usage. + +That number is very subject to change once we actually model the economy and start testing it. + +If someone completes the full base compendium, regardless of finish/material/etc., I think some kind of prestige system could be cool. + +Maybe it decorates the profile or binder rather than just resetting everything. + +### Binders + +Everyone gets a binder that holds their entire compendium that they can look through. + +Other people can look through a user's public collection/binder if that user allows it. + +Later we can have different binder designs and different ways to organize cards. This would let someone make dedicated collections such as: + +* All metal cards +* All holographic cards +* All foil cards +* All Catholic cards +* All church-history cards +* Whatever other collection they want to show off + +I previously mentioned microtransactions here, but there is currently no monetization plan. If this somehow becomes large enough that monetization matters, cosmetic binder/profile customization could be revisited later. + +### Packs + +I still need to deliberate on exactly how packs are awarded, how many cards each pack contains, and all of the statistics behind them. + +#### Working Daily-Pack Idea + +I still like having some kind of login pack because it establishes the initial hook. + +The user opens the app and sees the pack, which can immediately remind them: + +> Oh right. I need to actually accomplish my tasks today. + +One idea I like more than simply giving one pack per task is that the daily pack **upgrades as tasks are completed**. + +For example: + +* Login / start of day - very weak pack or only a couple cards. +* Complete one task - improve the pack. +* Complete additional tasks - improve odds, card count, or pack quality. +* Complete everything for the day - reach that day's maximum pack state. + +This keeps the login hook without making simply opening the application equivalent to actually doing the habits. + +I am not sure yet whether task completion should: + +* Improve rarity odds. +* Increase the number of cards in the pack. +* Improve expected wear / quality. +* Upgrade the pack type itself. +* Use some combination of the above. + +I particularly like the idea that the initial login pack might only have something like two cards and completing tasks adds additional cards to it. That may feel less casino-like than constantly manipulating rarity odds, but we need to test it. + +#### Saving Packs + +Users can choose to save earned packs and open them later. + +Some people may want to open every day. Other people may want to save ten or twenty packs and have one larger opening session. + +Once a day's pack is finalized, saving it should not continue to improve it later unless we intentionally design a mechanic around that. + +#### Pack Odds + +Pack probabilities should initially be globally fixed. + +We should still wire the probability variables in a way where they can be adjusted later if testing shows that the economy is developing too quickly or too slowly. + +I do **not** want probabilities dynamically changing per user behind the scenes just to manipulate engagement. + +#### Pack Types + +I still think different types of packs could be fun, with different expected card qualities or presentation. + +Initial placeholder ideas: + +* Common / normal +* Gold +* Illuminescent + +Names and exact meaning are still TBD. + +Different packs can have different opening animations. + +### Pack Opening Experience + +Opening cards is one of the most important parts of the entire application, so I think this is an area where we can intentionally be a little excessive. + +Possible opening modes: + +* Cards in a row, clicking each one to flip. +* Cards stacked front-to-back and revealed one at a time. +* Auto-open / open all for when the user does not care about the full animation. + +Rarity can affect anticipation without going full casino. + +For example, higher-rarity cards can have different pacing, lighting, sound, or reveal animation, but I do not want the application screaming flashing slot-machine effects at people. + +The reveal should feel special because the card is special, not because we are trying to mimic gambling psychology. + +Pack-opening presentation should be implemented by the active runtime renderer rather than depending on pre-rendered videos or Blender-authored animation clips. The sequence needs to respond to the cards actually awarded, support user interaction, scale across device capabilities, and remain skippable. + +The wrapper can be generated from a subdivided procedural mesh with controlled seams, tear paths, peel zones, and spring- or cloth-like deformation. Wrinkles, metallic reflections, particles, lighting, and sound can provide additional realism. A controlled and deterministic simulation is preferable to unrestricted cloth physics because it produces repeatable results, is easier to synchronize, and can degrade gracefully on lower-power devices. + +Blender is not required even if we pursue convincing wrapper tearing or crumpling. Runtime physics libraries, renderer extensions, skeletal deformation, morph targets, or custom shader deformation may be used where appropriate. Blender or another digital content creation tool remains optional for visual exploration or unusually complex assets. + +### Rarity + +I still want different tiers, although I want to find better Biblical/thematic names for them eventually. + +Placeholder tiers: + +* Common +* Uncommon +* Rare +* Extraordinary +* Legendary + +Rarity should represent **prominence within the collection**, not spiritual worth, holiness, or importance to God. + +I also really like the idea that different sets can have independent rarity structures. + +For example, rarity within a Biblical People set does not necessarily need to mean the exact same thing as rarity within a Church History set or a Catholic collection. + +This needs more work when we start building the actual card catalog. + +### What Makes a Card Unique + +At a high level, I think a card instance is made up of things such as: + +* Card Identity +* Artwork +* Rarity +* Finish +* Printing +* Material +* Wear +* Potential imperfections +* Provenance / who originally opened it and when + +There should still be a clean distinction between the base **card definition** and an individual **card instance**. + +For example: + +```text +Card Definition + David - Card 001 + Artwork A + Legendary + Biblical People Set + +Card Instance + David - Card 001 + Holographic + Borderless + Metal + 97.3% condition + Opened by Daniel + Opened on +``` + +That general contract should be renderer-independent. The database should not care whether Three.js, Flutter Scene, or some future engine is displaying the card. + +### Finish + +Possible finishes: + +* Matte +* Glossy +* Textured +* Holographic +* Foil +* Potentially multiple kinds of holographic/foil patterns later + +Different finishes should be visually obvious when interacting with the card. A holographic card should not just have a little "Holographic" tag underneath it. + +### Printing + +Placeholder printing types: + +* Normal +* Borderless +* Textless +* Boundless + +These can materially alter the card artwork/layout rather than just being metadata. + +### Material + +Possible materials: + +* Paper +* Plastic +* Wood +* Linen +* Metal + +Material should affect the way the card looks and reacts to light. + +### Wear / Condition + +I still like every normal card instance having a wear/condition value between `0.00` and `1.00`, but I am reconsidering what the low end actually looks like. + +`1.00` would be essentially pristine. + +`0.00` does **not** need to mean the card looks like it went through a washing machine. It could still be clearly readable and attractive, just visibly scratched / worn / damaged compared to a mint card. + +We can expose condition to users as percentages and named bands. + +Very rough example: + +* Mint: `1.00 - 0.95` +* Minimal Wear: `0.95 - 0.75` +* Additional bands: TBD + +The actual ranges need to be modeled and tested rather than guessed here. + +The wear value should affect visible rendering where practical: + +* Edge whitening +* Surface scratches +* Small scuffs +* Roughness changes +* Other subtle damage + +Even poor-condition cards should still look like something someone wants to collect. + +### Weight + +I still like the completely unnecessary physical-card idea that cards have simulated weight. + +Cards might have a baseline around 100g with slight variance, while material/finish can add or subtract minor amounts. + +Eventually there could be an achievement or unlockable digital scale that lets the user weigh an unopened pack and try to infer what might be inside. + +This is very much a future fun feature and not core functionality. + +### Imperfections + +This is still a maybe. + +We could generate some number of masks for things like: + +* Fingerprints +* Hair +* Minor print artifacts +* Surface marks + +Then an individual card instance could have a very small chance of receiving one based on a deterministic seed. + +This would make otherwise-identical variants slightly more interesting. + +### Population / Provenance + +Cards should retain metadata about when they were opened and who originally opened them. + +I also like tracking population for meaningful combinations such as: + +```text +Card ID + Finish + Printing + Material +``` + +That way someone can see that their metallic holographic borderless Adam is 1 of 1, while another combination may already have 1,000 copies. + +I don't think wear, weight, and individual imperfections should count toward this population grouping because then effectively everything becomes a fake 1-of-1. + +### Card Economy / Destruction + +This is one area I want to work on more specifically. + +One mechanic that might be useful is some kind of trade-up / crafting system similar in spirit to Counter-Strike trade-ups. + +For example: + +> Consume 20 Common cards to generate 1 Uncommon card. + +The exact number and output obviously need to be modeled. + +I like this because it gives duplicates a use while also permanently removing cards from circulation. + +Questions we need to answer later: + +* Is the output guaranteed to be the next rarity? +* Does finish/material influence the result? +* Can users choose which set the resulting card comes from? +* Are some cards protected from trade-ups? +* Does a burned/traded-up card remain in provenance history as destroyed? + +I think actual destruction can become an important sink in the economy, but it should mostly be voluntary rather than punishment. + +### Content + +I want to eventually have a very large repository of cards. + +Possible categories include: + +* People +* History +* Icons +* Items +* Places +* Events +* Theologians +* Theology +* Apologetics / arguments +* Councils +* Traditions +* Controversies +* Probably many more once we actually start cataloging things + +Unless it is a specific printing such as textless, every card should have meaningful text associated with it. + +Priority for card text: + +1. Scripture where directly relevant. +2. Quote where directly relevant. +3. Historical / theological fact. + +There should also be a way to click through and learn more or understand why the text/artwork is associated with that card. + +The educational side is important. Pulling something unfamiliar should be an invitation to learn what it is rather than just seeing a rarity number. + +### Art Style + +I still have not decided how I want the cards themselves to look. + +This is something where I will probably lean heavily on LLM/image-generation tooling and MCP servers to help explore art direction since I am a software engineer, not an artist or 3D modeler. + +I think we should generate a bunch of visual directions before committing to one common design language. + +Tradition/set flavor can affect things such as: + +* Colors +* Borders +* Typography +* Card backs +* Ornamentation +* Maybe 3D material treatment + +--- + +## Visual Experience + +The visual experience is not just decoration for this application. Opening and physically inspecting the collectible is part of the reward. + +### Card Interaction + +Cards should be represented as tactile physical objects rather than static collectible images wherever practical. + +When inspecting a card, the user must be able to: + +* Rotate the card freely. +* Flip between front and back. +* Zoom in to inspect artwork and physical characteristics. +* Observe material properties responding dynamically to lighting. +* Observe wear and imperfections where applicable. +* Return quickly to the originating collection/binder context. + +Card finish and material must have visually distinguishable properties. For example, foil, holographic, paper, linen, and metal cards should respond differently to lighting. + +Lower-power devices must be able to fall back to simplified visual representations without affecting gameplay. + +### Where 3D Is Used + +I do **not** think every card in every screen should be an actively rendered 3D object. + +The primary 3D use cases should be: + +* Pack openings +* Full card inspection +* Potentially special collection/showcase views later + +Normal binder pages, search, trading lists, etc. can use static or pre-rendered thumbnails until the user chooses to inspect a card. + +This should keep the application responsive while still making the moments that matter visually interesting. + +### Visual Properties Should Matter + +If we add a collectible property, the user should ideally be able to see or interact with it somehow. + +For example: + +```text +Finish -> visible +Material -> visible +Wear -> visible +Imperfections -> visible +Printing -> visible +Weight -> indirectly observable +Provenance -> inspectable metadata +Population -> inspectable metadata +``` + +Otherwise there is not much point in storing increasingly complicated metadata that never changes the actual experience. + +--- + +## Social Interaction + +The other important aspect of card collections is trading and having some kind of community around them. + +Users can have friends and accountability partners. Those are intentionally different concepts because someone may want to trade/show collections to a lot of people while keeping accountability very private. + +### Initial Social Functionality + +Things I think would be great: + +* View another user's public compendium / binder. +* Offer trades. +* Add friends. +* Add explicit accountability partners. +* Send predetermined messages of encouragement. +* Send Scripture / verses. +* Send generic prayer requests without revealing why. + +### No General Chat + +I do not want general chat integration. + +That creates a massive moderation problem and can very quickly turn into the application becoming a social network instead of the thing I actually want to build. + +Predetermined messages and controlled interactions should cover most of what is useful here without adding an entire moderation product. + +### Accountability + +All habit tracking is private unless explicitly opened to accountability partners. + +Even then, the default shared state should be obscure rather than detailed. + +For example: + +> Daniel asked for prayer today. + +rather than: + +> Daniel failed Habit X at 9:42 PM. + +The user can choose to share exact habits/details with a specific accountability partner if they want to. + +This avoids both hubris and shame - neither of those is the point of the project. + +--- + +## Monetization + +I genuinely have no real plan to make money off this right now. + +My current plan is to build it, use it myself, and try it with friends. + +If it somehow blows up in popularity, then infrastructure costs and monetization can be figured out later. + +One thing I do want to make an explicit product principle now: + +**Absolutely no paid loot boxes or buying better pack odds.** + +Gambling already ruins lives and building that psychology into an application that is supposed to help people grow in Christ would be anathema to what I am trying to accomplish. + +If monetization ever becomes necessary, things like purely cosmetic binder designs, profile customization, or other non-random features can be considered separately. + +Money should not buy spiritual-habit rewards or manipulate rarity odds. + +--- + +## Integrations + +I still think API and webhook support would be useful eventually so the application can integrate into other things. + +Examples: + +* Fluxer bot +* Discord bot +* Other automation / personal tooling + +Exactly what gets exposed and when is a technical/API-spec question. + +This is not a priority for the first MVP. + +--- + +## MVP + +There is not really a formal deliverable or deadline right now. This is a project I want to build because the product is interesting and some of the technical problems are fun. + +Since I am a software engineer and not an artist or 3D modeler, I expect to lean on LLMs, MCP servers, image generation, and other tooling to help with those parts. + +I think the MVP should intentionally focus on the difficult / interesting pieces rather than spending months filling out content. + +### Initial MVP Scope + +Something like: + +* Web client first. +* Works on desktop and mobile browsers. +* Basic account/authentication. +* Positive habit tracking. +* Negative habit tracking with abstinence + honesty/engagement concepts. +* Consistency metrics instead of positive-habit streaks. +* Daily pack/reward prototype. +* Ability to save packs. +* Around 50 real cards. +* Multiple rarity levels. +* Multiple finishes/materials/printings. +* Wear rendering. +* Binder / compendium. +* Full 3D card inspection. +* 3D pack-opening flow. +* Basic population/provenance model. +* Enough economy simulation to begin testing rarity math. + +I would rather have **50 cards where all of the difficult rendering/material/variant systems actually work** than 500 cards that are basically static images. + +The rendering canvas, shaders/materials, and card pipeline are some of the more interesting technical challenges to me. + +Wiring up a backend to a client, normal CRUD, auth, API calls, etc. are much more familiar problems and can be fleshed out separately in the technical spec. + +### Not Required for the First MVP + +Likely later: + +* Native Android/iOS applications. +* Full trading economy. +* Advanced social functionality. +* API/webhook ecosystem. +* Huge card catalog. +* Digital pack scale / weight mechanics. +* Deep achievement/prestige systems. +* Monetization. + +### MVP art-direction rules + +Seen in [Art Direction](./art-direction.md) + +### Sample MVP Collection + +#### Permanent starter + +| ID | Card | Type | Notes | +| ----- | --------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S-001 | **The Trinity** | Foundation | Permanent, nontradeable, cannot be burned or consumed, always mint. User can choose/customize finish/material/printing. Suggested verse: Matthew 28:19. | + + +#### Others + +| # | Card | Category / Set | Working Rarity | Content / Visual Hook | +| --: | --------------------------------- | --------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 001 | **Adam** | Scripture · Person | Rare | Genesis 2–3. Excellent portrait/environment card; Eden imagery. | +| 002 | **Eve** | Scripture · Person | Rare | Genesis 2–3. Companion visually to Adam without making them a paired requirement. | +| 003 | **Noah** | Scripture · Person | Uncommon | Genesis 6–9. Rain, ark, rainbow imagery. | +| 004 | **Abraham** | Scripture · Person | Extraordinary | Genesis 12–22. Stars/sky imagery could make an excellent foil. | +| 005 | **Sarah** | Scripture · Person | Uncommon | Genesis 18, 21. | +| 006 | **Moses** | Scripture · Person | Legendary | Burning bush / Sinai gives us several strong artwork directions. | +| 007 | **Ruth** | Scripture · Person | Common | Book of Ruth. Wheat-field imagery; intentionally beautiful despite Common rarity. | +| 008 | **David** | Scripture · Person | Legendary | Shepherd/king imagery. Could support multiple artwork printings eventually. | +| 009 | **Elijah** | Scripture · Person | Rare | 1 Kings 17–19. Fire-heavy card effects would be useful renderer testing. | +| 010 | **Isaiah** | Scripture · Person | Rare | Isaiah 6 / prophetic imagery. | +| 011 | **Jonah** | Scripture · Person | Common | Jonah. Strong visual identity even at Common. | +| 012 | **Mary** | Scripture · Person | Extraordinary | Luke 1–2. Could eventually have tradition-specific artwork variants. | +| 013 | **Joseph of Nazareth** | Scripture · Person | Uncommon | Matthew 1–2. | +| 014 | **John the Baptist** | Scripture · Person | Rare | Matthew 3 / John 1. Water/wilderness imagery. | +| 015 | **Peter** | Scripture · Person | Legendary | Gospels / Acts. Keys could appear in some tradition-specific printings without defining the base card around them. | +| 016 | **Paul** | Scripture · Person | Legendary | Acts 9 + epistles. | +| 017 | **Mary Magdalene** | Scripture · Person | Rare | John 20. Resurrection witness gives the card a clear textual focus. | +| 018 | **Stephen** | Scripture · Person | Common | Acts 6–7. | +| 019 | **Timothy** | Scripture · Person | Common | Acts 16 / Timothy. | +| 020 | **Gabriel** | Scripture · Angel | Rare | Luke 1. Visually lets us test a non-human person card. | +| 021 | **Creation** | Scripture · Event | Extraordinary | Genesis 1. Very different composition from portrait cards. | +| 022 | **The Flood** | Scripture · Event | Uncommon | Genesis 6–9. Water/rain shader experimentation. | +| 023 | **The Exodus** | Scripture · Event | Extraordinary | Exodus 12–14. Sea/fire/cloud imagery. | +| 024 | **The Ten Commandments** | Scripture · Event | Rare | Exodus 20. Stone/tablet materials make this particularly useful visually. | +| 025 | **The Nativity** | Scripture · Event | Extraordinary | Luke 2. | +| 026 | **The Baptism of Jesus** | Scripture · Event | Rare | Matthew 3. | +| 027 | **The Last Supper** | Scripture · Event | Rare | Luke 22 / 1 Corinthians 11. | +| 028 | **The Crucifixion** | Scripture · Event | Legendary | Central subject, but rarity represents prominence in this collection rather than "holiness." | +| 029 | **The Resurrection** | Scripture · Event | Legendary | Excellent candidate for one of the MVP's most visually elaborate cards. | +| 030 | **Pentecost** | Scripture · Event | Extraordinary | Acts 2. Fire/light effects. | +| 031 | **The Ark of the Covenant** | Scripture · Item | Rare | Exodus 25. Excellent first metal/gold-material showcase. | +| 032 | **Bethlehem** | Scripture · Place | Common | Micah 5:2 / Luke 2. | +| 033 | **Jerusalem** | Scripture · Place | Rare | Lets us establish how location cards differ visually from person/event cards. | +| 034 | **The Sea of Galilee** | Scripture · Place | Common | Strong environmental artwork; water reflection testing. | +| 035 | **The Empty Tomb** | Scripture · Place | Extraordinary | Resurrection-associated without duplicating the Resurrection event card. | +| 036 | **The Nicene Creed** | Core · Doctrine/History | Legendary | Basically the thesis statement for the app's doctrinal baseline. | +| 037 | **The Council of Nicaea** | Church History · Event | Extraordinary | AD 325. Also introduces historical-event cards outside Scripture. | +| 038 | **Athanasius of Alexandria** | Church History · Theologian | Rare | Natural connection to Nicaea and the Incarnation. | +| 039 | **The Incarnation** | Core · Theology | Extraordinary | John 1:14 provides a strong scriptural anchor. | +| 040 | **The Resurrection of the Dead** | Core · Theology | Uncommon | 1 Corinthians 15 / Nicene Creed. | +| 041 | **The Great Commission** | Core · Teaching | Uncommon | Matthew 28:18–20. | +| 042 | **The Lord's Prayer** | Core · Teaching | Common | Matthew 6:9–13. Good example of a Common card that everyone should still want. | +| 043 | **The Sermon on the Mount** | Core · Teaching | Uncommon | Matthew 5–7. | +| 044 | **The Rosary** | Catholic · Item/Practice | Uncommon | Tests explicitly tradition-specific educational material. | +| 045 | **Christ Pantocrator** | Orthodox · Icon | Rare | Gives the MVP an actual icon card and a very different visual format. | +| 046 | **Martin Luther** | Protestant · Theologian | Rare | Reformation history; explicitly Protestant-tagged while collectible by everyone. | +| 047 | **The Book of Common Prayer** | Protestant · Item/History | Uncommon | Anglican-specific, which also starts proving that "Protestant" doesn't need to be treated as one monolithic tradition. | +| 048 | **Arianism** | Controversies · Theology | Uncommon | Explain what Arius taught, why Nicaea responded, and what the Nicene position is. | +| 049 | **The Latter-day Saint Movement** | Controversies · Movement | Uncommon | Educational treatment; explain where its doctrine of God differs from the app's Nicene standard. | +| 050 | **Jehovah's Witnesses** | Controversies · Movement | Uncommon | Same approach: describe rather than mock, then clearly compare with the Nicene baseline. | + + +I'd choose maybe 10 "hero cards" and deliberately make them exercise the most difficult rendering features: + +| Hero card | Rendering experiment | +| --------------------- | ---------------------------------------- | +| Moses | emissive/fire | +| Elijah | foil + fire | +| Creation | borderless + large environmental artwork | +| Exodus | animated/specular water | +| Resurrection | premium holographic treatment | +| Pentecost | emissive/fire | +| Ark of the Covenant | metallic gold | +| Nicene Creed | text-heavy card | +| Christ Pantocrator | textured/icon treatment | +| Book of Common Prayer | linen/paper/embossing | + + +--- + +## Initial Technical Direction + +The functional requirements should stay mostly engine-independent, but the way the cards look and behave is important enough that we need to consider rendering architecture early. + +### High-Level Architecture + +The web client is the MVP because it gets us something usable on desktop and mobile devices immediately. + +Native Android/iOS are still the north star if the project proves worth continuing. + +```text + Backend / API + | + Shared Card Model + | + +----------+----------+ + | | + Web Client Flutter Client + (MVP) (Android / iOS) + | | + +------+-------+ +------+-------+ + | | | | + Normal UI 3D Renderer Flutter UI 3D Renderer +``` + +The important part is that the **shared card model and backend contracts are renderer-independent**. + +### Card Rendering Contract + +The general idea is that the renderer receives a contract describing what the card is rather than the database storing engine-specific details. + +Very rough example: + +```json +{ + "cardId": "david-001", + "artworkId": "david-a", + "finish": "holographic", + "material": "metal", + "printing": "borderless", + "wear": 0.973, + "imperfectionSeed": 81251 +} +``` + +Then the active renderer interprets that contract. + +That gives us room to use different renderers on different clients without touching the card economy or rewriting the backend model. + +### Asset Pipeline + +Current direction: + +```text +Card definition + artwork + masks + | + v + Shared visual specification + (dimensions, radius, thickness, UVs) + | + +-------+-------+ + | | + v v + Three.js renderer Native renderer +``` + +The runtime renderer should own the standard card geometry, materials, lighting, wear, inspection interactions, card flips, and pack-opening animation. Standard card geometry is simple and parameterized enough to generate directly from the shared visual specification rather than requiring a `.blend` file or GLB export. + +Artwork, masks, card metadata, and finish/material selections should remain engine-independent inputs. Each renderer interprets those inputs using its own runtime materials and shaders. Holographic, foil, substrate, embossing, wear, and other view-dependent effects must therefore have explicit runtime implementations rather than relying on Blender material-node export. + +Blender is not a production dependency for cards or pack openings. It can remain an optional tool for visual references, promotional renders, material experiments, or future bespoke 3D assets that are not practical to construct procedurally. If such an asset is introduced, it may be exported through glTF/GLB without changing the standard runtime-owned card pipeline. + +### Renderer Technical Spike - Next Step + +The next major technical question should be a renderer spike comparing: + +#### Three.js + +Likely strongest candidate for the web MVP and probably via React Three Fiber if the web application is React-based. + +Things to test: + +* Holographic materials +* Foil +* Metal +* Wear masks +* Lighting +* Rotation/zoom/touch controls +* Pack-opening animation +* Controlled wrapper tearing, peeling, and crumpling +* Mobile browser performance + +#### Flutter Scene / Flutter GPU + +Worth testing because native Android/iOS are the eventual goal and a good result here could let us build a native renderer without embedding the web renderer forever. + +Things to test should be the same so we are comparing real output rather than toy demos. + +#### Prototype the Hard Card + +Instead of testing with a plain paper Common card, the technical spike should deliberately build something absurd: + +> Legendary + borderless + metal + holographic + embossed + visible wear + +If both renderers can handle the hardest card nicely, normal cards should be straightforward. + +The spike should compare: + +* Visual fidelity +* Shader/material flexibility +* Performance on desktop +* Performance on mobile +* Touch interaction quality +* Developer ergonomics +* Asset pipeline complexity +* How much renderer code can realistically be shared between web and future native clients + +We can decide the rendering engine after that rather than locking ourselves in beforehand. + +--- + +## Open Questions / Areas to Workshop + +### Pack Economy + +* How many cards are in the initial login pack? +* Does each completed task add cards, improve rarity, improve condition, upgrade the pack, or some mixture? +* How large should the difference be between an untouched login pack and a fully upgraded daily pack? +* How do weekly habits such as church attendance affect the pack? +* Are there separate weekly/monthly packs even though we are moving away from streaks? +* How quickly should one person be able to complete the base compendium? + +### Rarity / Trade-Ups + +* Exact rarity probabilities. +* Exact expected supply assuming perfect habit completion. +* How set-specific rarity works. +* How many lower-rarity cards are required for a trade-up. +* Whether finish/material/printing affect trade-up outputs. +* What happens to destroyed-card provenance. + +### Wear + +* Exact condition bands. +* Lowest acceptable visual condition. +* Whether higher rarity cards have condition floors. +* How heavily wear should affect collectability/value. + +### Trinity Card + +* Final name. +* John 3:16 or another verse. +* Artwork that communicates the idea without bad or irreverent depiction. +* How customizable finishes/materials should look. + +### Christian Content + +* Who ultimately reviews doctrinal/content accuracy? +* How deep tradition-specific collections go. +* Exact taxonomy for controversies / non-Nicene movements. +* How citations/sources are stored and displayed on cards. + +### Rendering + +* Three.js vs Flutter Scene technical spike. +* Exact web framework. +* Shader/material design. +* Runtime asset-generation workflow and optional digital content creation tooling. +* Static thumbnail generation from the same card contract. + +### Social / Trading + +* When trading belongs in the roadmap. +* Whether population numbers are public by default. +* How offers work. +* Whether destroyed cards remain visible in historical collection records. +* What parts of a user's binder/profile are private versus public. + +### Privacy + +* Exact client-side encryption model. +* Whether BYOK is practical for normal users. +* Account recovery when encryption keys are lost. +* What anonymous/statistical data can be collected without weakening the privacy model. +* Exact accountability-sharing permissions. diff --git a/sanctification-tcg/legendary.png b/sanctification-tcg/legendary.png new file mode 100644 index 0000000..9eb7cd0 Binary files /dev/null and b/sanctification-tcg/legendary.png differ