Compare commits
2 Commits
8355743ada
...
56f010a71e
| Author | SHA1 | Date | |
|---|---|---|---|
| 56f010a71e | |||
| 8fcda5f2b7 |
@@ -25,7 +25,7 @@ Across the whole product, the artwork should generally follow these ideas:
|
|||||||
- Sacred and reverent tone.
|
- Sacred and reverent tone.
|
||||||
- Stained-glass-inspired composition.
|
- Stained-glass-inspired composition.
|
||||||
- Jewel-tone palette where appropriate.
|
- Jewel-tone palette where appropriate.
|
||||||
- Gold can be used as an accent, but should become more prominent as rarity increases.
|
- Gold begins at Uncommon and may become more prominent as rarity increases; Common uses no gold.
|
||||||
- Tapestry, linen, parchment, and illuminated-manuscript textures can support the overall look.
|
- Tapestry, linen, parchment, and illuminated-manuscript textures can support the overall look.
|
||||||
- Clear subject silhouettes.
|
- Clear subject silhouettes.
|
||||||
- Cards should remain readable at thumbnail size.
|
- Cards should remain readable at thumbnail size.
|
||||||
@@ -91,9 +91,10 @@ For character cards, prefer:
|
|||||||
|
|
||||||
- One clear subject.
|
- One clear subject.
|
||||||
- Strong silhouette.
|
- Strong silhouette.
|
||||||
- Limited background complexity.
|
- Background complexity appropriate to rarity.
|
||||||
- Symbolic items associated with the subject.
|
- Symbolic items associated with the subject.
|
||||||
- Calm, iconic poses over excessive action scenes unless the card itself represents an event.
|
- For Common, wider and calmer compositions with the subject in the middle distance doing one modest action.
|
||||||
|
- Reserve tighter, iconic presentation for higher rarities where appropriate. Legendary may also achieve grandeur through a large scene or a spacious, contemplative composition.
|
||||||
|
|
||||||
For event cards, larger compositions and multiple figures can be used when necessary.
|
For event cards, larger compositions and multiple figures can be used when necessary.
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ A normal card can contain:
|
|||||||
|
|
||||||
- Name.
|
- Name.
|
||||||
- Short subtitle or descriptor.
|
- Short subtitle or descriptor.
|
||||||
|
- One quiet subject-type label, such as Person, Event, Place, Object, or Doctrine.
|
||||||
- Short verse, quote, or historical line.
|
- Short verse, quote, or historical line.
|
||||||
- Reference.
|
- Reference.
|
||||||
|
|
||||||
@@ -134,6 +136,18 @@ A short excerpt with an ellipsis is fine when it is clearly intentional.
|
|||||||
|
|
||||||
The full content can appear in the inspection panel.
|
The full content can appear in the inspection panel.
|
||||||
|
|
||||||
|
### Text Budget and Placement
|
||||||
|
|
||||||
|
- Name: one line where practical, with an approved two-line layout for long names.
|
||||||
|
- Subtitle: optional; include it only when it adds useful information.
|
||||||
|
- Main excerpt or fact: initially target about 15-25 words, then validate actual typeset fit.
|
||||||
|
- Reference: a short, readable attribution. Full citations and supporting sources belong in the inspection panel.
|
||||||
|
- Use fixed text boxes, approved fonts, line spacing, alignment, and a minimum readable size for each template.
|
||||||
|
- Measure actual rendered text. If it overflows, edit the wording, select a shorter faithful excerpt, or use an approved alternate layout. Do not silently shrink or clip it.
|
||||||
|
- Typeset all lettering separately from generated imagery. Textless and Boundless contain no lettering, including the name and category.
|
||||||
|
|
||||||
|
The subject-type label should come from the Master Card Catalog taxonomy. History, period, tradition, and collection grouping belong in the inspection UI; they are different dimensions from Person or Event. Category should not introduce its own border colors or decorative badges.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Card Back
|
## Card Back
|
||||||
@@ -167,7 +181,6 @@ When inspecting a card, the user should be able to:
|
|||||||
- Flip between front and back.
|
- Flip between front and back.
|
||||||
- Zoom in to inspect artwork and physical characteristics.
|
- Zoom in to inspect artwork and physical characteristics.
|
||||||
- Observe material properties responding dynamically to lighting.
|
- Observe material properties responding dynamically to lighting.
|
||||||
- Observe wear and imperfections where applicable.
|
|
||||||
- Return quickly to the originating collection / binder context.
|
- 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.
|
The inspection UI should also expose the educational and collectible information that does not need to live directly on the physical card.
|
||||||
@@ -191,8 +204,6 @@ Possible sections:
|
|||||||
- Printing.
|
- Printing.
|
||||||
- Finish.
|
- Finish.
|
||||||
- Material.
|
- Material.
|
||||||
- Wear classification.
|
|
||||||
- Exact wear percentage.
|
|
||||||
|
|
||||||
## Provenance
|
## Provenance
|
||||||
|
|
||||||
@@ -222,7 +233,7 @@ requires otherwise.
|
|||||||
|
|
||||||
The standard master artwork resolution should be:
|
The standard master artwork resolution should be:
|
||||||
|
|
||||||
**3000 × 4200 px**
|
**2000 × 2800 px**
|
||||||
|
|
||||||
This is the source-quality asset and should be retained even if smaller
|
This is the source-quality asset and should be retained even if smaller
|
||||||
versions are generated for the application.
|
versions are generated for the application.
|
||||||
@@ -234,7 +245,6 @@ practical, including:
|
|||||||
- Holographic masks
|
- Holographic masks
|
||||||
- Metallic masks
|
- Metallic masks
|
||||||
- Roughness maps
|
- Roughness maps
|
||||||
- Wear masks
|
|
||||||
- Normal/bump maps
|
- Normal/bump maps
|
||||||
- Printing masks
|
- Printing masks
|
||||||
|
|
||||||
@@ -254,6 +264,33 @@ runtime export.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Layered Card Production
|
||||||
|
|
||||||
|
Every card starts with full-card illustration, composed to work with the Normal printing's overlays. Essential subjects and storytelling remain within its visible art area. The regions revealed by other printings must also feel intentionally illustrated.
|
||||||
|
|
||||||
|
Author these components independently:
|
||||||
|
|
||||||
|
1. **Base art:** full-card illustration without lettering, a card frame, or a text panel baked into it.
|
||||||
|
2. **Outer frame:** reusable tier ornament with a transparent interior.
|
||||||
|
3. **Text backing:** the title and supporting-text panel shapes, without lettering.
|
||||||
|
4. **Text:** deterministic typography from the card's content data.
|
||||||
|
|
||||||
|
Outer frame and text backing are editable parts of the same border system. Export three border overlays from them: combined frame/backing, frame only, and backing only. Each printing uses at most one of these overlays.
|
||||||
|
|
||||||
|
Common through Extraordinary each use one approved frame system per rarity initially. Reuse its geometry, colors, ornament, and text regions across cards in that tier. Subject-specific symbols belong in the illustration. Legendary artwork variants may use bespoke artwork, frames, and layouts while retaining the shared printing definitions and text-readability requirements.
|
||||||
|
|
||||||
|
Generation supplies imagery and decoration; a versioned template supplies exact geometry. All production layers use the full 2000 × 2800 canvas, the same origin, and recorded transforms. Do not trim transparent margins and then attempt to realign layers by eye.
|
||||||
|
|
||||||
|
Create frame and backing masks from shared vector paths. Extend their joining regions underneath one another so separately antialiased edges cannot expose a seam. For Normal, export the assembled overlay as one image. Derive the other overlays from the same geometry.
|
||||||
|
|
||||||
|
Transparency masks and finish masks serve different purposes. Transparency determines visible composition; finish masks determine where surface treatment responds to light. Assemble finish coverage for each printing from its active components, with text protection derived from that printing's actual text regions. Removing a text backing also removes its associated protection.
|
||||||
|
|
||||||
|
Keep an editable layered master and export the composed card face plus aligned finish maps. Three.js continues to supply materials, finish response, and interaction. Changing finish or substrate should not require regenerating the illustration.
|
||||||
|
|
||||||
|
The proposed template coordinates, validation checks, and GIMP/SVG tooling workflow are recorded in [Card Layer Pipeline](./card-layer-pipeline.md). The first template remains a proof to review, not a final approved layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Rarity Art Direction
|
# Rarity Art Direction
|
||||||
|
|
||||||
Rarity should have a major impact on the visual grammar of the card.
|
Rarity should have a major impact on the visual grammar of the card.
|
||||||
@@ -275,7 +312,9 @@ Rarity should **not** simply mean adding more glow or visual effects.
|
|||||||
|
|
||||||
A useful principle:
|
A useful principle:
|
||||||
|
|
||||||
> **Higher rarity should add storytelling and ornamentation, not merely more effects.**
|
> **Higher rarity should have greater narrative and visual presence. Ornament is one way to achieve that; scale, light, beauty, and restraint are others.**
|
||||||
|
|
||||||
|
Rarity describes collectible presentation, never the spiritual worth of a person or subject.
|
||||||
|
|
||||||
Another way to think about it:
|
Another way to think about it:
|
||||||
|
|
||||||
@@ -301,11 +340,12 @@ Common cards should be intentionally restrained.
|
|||||||
- Limited palette.
|
- Limited palette.
|
||||||
- Roughly 2-3 dominant colors plus cream / parchment.
|
- Roughly 2-3 dominant colors plus cream / parchment.
|
||||||
- More muted or restrained jewel tones.
|
- More muted or restrained jewel tones.
|
||||||
- Gold used mostly for separation or small accents.
|
- No gold accents or gold dividers.
|
||||||
|
|
||||||
### Artwork
|
### Artwork
|
||||||
|
|
||||||
- It doesn't need to be just the subject but rather them in the background doing something
|
- Show the subject in the middle distance doing one modest action, rather than presenting a headshot or dominant frontal portrait.
|
||||||
|
- Preserve a clear focal subject through placement and contrast; do not make the person an unreadable background speck.
|
||||||
- Simple background.
|
- Simple background.
|
||||||
- Larger stained-glass panes.
|
- Larger stained-glass panes.
|
||||||
- Fewer small glass segments.
|
- Fewer small glass segments.
|
||||||
@@ -326,16 +366,15 @@ Example:
|
|||||||
|
|
||||||
**Timothy**
|
**Timothy**
|
||||||
|
|
||||||
- Timothy holding a scroll.
|
- Timothy seated beside a modest doorway, reading a scroll with another person.
|
||||||
- Simple ancient street or landscape.
|
- A simple wall, street, and sky described with large glass panes.
|
||||||
- Simple arch.
|
- Blue / muted green / cream palette.
|
||||||
- Blue / green / cream palette.
|
- Thin geometric frame and plain divider.
|
||||||
- Thin brass or gold divider.
|
|
||||||
- No ornate botanical frame.
|
- No ornate botanical frame.
|
||||||
- No special corner symbols.
|
- No special corner symbols.
|
||||||
- No heavily gilded title plaque.
|
- No heavily gilded title plaque.
|
||||||
|
|
||||||

|
The earlier Timothy reference is too elaborate for the revised Common target and should not define its visual ceiling. Retain the existing harness fixture for material comparison while creating the new proof.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -379,8 +418,8 @@ Rare cards should feel distinctive.
|
|||||||
### Border
|
### Border
|
||||||
|
|
||||||
- Decorative frame.
|
- Decorative frame.
|
||||||
- More custom ornament.
|
- More ornament within the shared Rare frame design.
|
||||||
- Symbolic motifs related to the subject.
|
- Subject-specific symbolic motifs remain in the illustration.
|
||||||
|
|
||||||
### Color
|
### Color
|
||||||
|
|
||||||
@@ -411,7 +450,7 @@ Extraordinary cards should feel prestigious.
|
|||||||
|
|
||||||
- Elaborate framing.
|
- Elaborate framing.
|
||||||
- Strong illuminated-manuscript influence.
|
- Strong illuminated-manuscript influence.
|
||||||
- More bespoke ornamentation.
|
- Richer ornamentation within the shared Extraordinary frame design.
|
||||||
- Larger use of gold.
|
- Larger use of gold.
|
||||||
|
|
||||||
### Color
|
### Color
|
||||||
@@ -446,22 +485,22 @@ They can break some of the normal card-layout rules while still clearly belongin
|
|||||||
|
|
||||||
- Highly bespoke.
|
- Highly bespoke.
|
||||||
- Custom symbolic elements related specifically to the card.
|
- Custom symbolic elements related specifically to the card.
|
||||||
- Extensive but tasteful ornamentation.
|
- Ornamentation chosen to serve the artwork; density is not a requirement.
|
||||||
- Rich gilding.
|
- Tasteful gilding where appropriate, without requiring a maximum amount of gold.
|
||||||
|
|
||||||
### Color
|
### Color
|
||||||
|
|
||||||
- Full jewel-tone palette where appropriate.
|
- Full jewel-tone palette where appropriate.
|
||||||
- Strong gold accents.
|
- Gold accents where they support the composition.
|
||||||
- Deep color contrast.
|
- Deep color contrast.
|
||||||
|
|
||||||
### Artwork
|
### Artwork
|
||||||
|
|
||||||
- Strong visual storytelling.
|
- Strong visual storytelling.
|
||||||
- Multiple symbols connected directly to the subject.
|
- One governing narrative or visual idea, with supporting symbols chosen deliberately.
|
||||||
- Complex stained-glass composition.
|
- Exceptional composition through scale, light, beauty, or restraint.
|
||||||
- More dramatic light.
|
- Epic scenes may use immense environments and consequential moments.
|
||||||
- More detailed environments.
|
- Contemplative scenes may use spacious composition, restrained symbolism, and remarkable light.
|
||||||
- Unique framing or iconography.
|
- Unique framing or iconography.
|
||||||
|
|
||||||
### Overall Feel
|
### Overall Feel
|
||||||
@@ -489,6 +528,8 @@ Possible visual elements:
|
|||||||
|
|
||||||
These elements help tell David's story rather than simply adding decoration for decoration's sake.
|
These elements help tell David's story rather than simply adding decoration for decoration's sake.
|
||||||
|
|
||||||
|
They are possibilities, not a checklist for one image. A Legendary may contain fewer elements than an Extraordinary while having greater presence. Different artwork variants can explore different governing ideas.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
@@ -523,7 +564,7 @@ A Legendary plain-paper card should still visually read as Legendary.
|
|||||||
- Surface microtexture and roughness.
|
- Surface microtexture and roughness.
|
||||||
- Edge and core appearance.
|
- Edge and core appearance.
|
||||||
- Base reflectivity and perceived weight.
|
- Base reflectivity and perceived weight.
|
||||||
- Paper, linen, plastic, metal, or wood response.
|
- Paper, linen, metal, or wood response.
|
||||||
|
|
||||||
## Finish controls
|
## Finish controls
|
||||||
|
|
||||||
@@ -553,8 +594,6 @@ Examples:
|
|||||||
|
|
||||||
- Finish -> visible.
|
- Finish -> visible.
|
||||||
- Material -> visible.
|
- Material -> visible.
|
||||||
- Wear -> visible.
|
|
||||||
- Imperfections -> visible.
|
|
||||||
- Printing -> visible.
|
- Printing -> visible.
|
||||||
- Weight -> indirectly observable.
|
- Weight -> indirectly observable.
|
||||||
- Provenance -> inspectable metadata.
|
- Provenance -> inspectable metadata.
|
||||||
@@ -570,13 +609,6 @@ The material is the card's substrate. The finish is a treatment applied over tha
|
|||||||
- Broad, weak reflection rather than a sharp sheen.
|
- Broad, weak reflection rather than a sharp sheen.
|
||||||
- Grain should become visible during close inspection without making the artwork noisy.
|
- 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
|
## Linen
|
||||||
|
|
||||||
- Raised interlaced warp and weft rather than generic noise.
|
- Raised interlaced warp and weft rather than generic noise.
|
||||||
@@ -598,7 +630,7 @@ The material is the card's substrate. The finish is a treatment applied over tha
|
|||||||
- Warm substrate tint with a darker brown core.
|
- Warm substrate tint with a darker brown core.
|
||||||
- Longitudinal, slightly warped grain with shallow physical relief.
|
- Longitudinal, slightly warped grain with shallow physical relief.
|
||||||
- Grain should support the card artwork rather than overpowering it.
|
- Grain should support the card artwork rather than overpowering it.
|
||||||
- Reflection should remain restrained and less polished than plastic or metal.
|
- Reflection should remain restrained and less polished than metal.
|
||||||
|
|
||||||
## Foil
|
## Foil
|
||||||
|
|
||||||
@@ -618,76 +650,28 @@ The material is the card's substrate. The finish is a treatment applied over tha
|
|||||||
|
|
||||||
- Finish masks should be authored and reviewed as part of the card art rather than treated as arbitrary technical output.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
---
|
Use the approved [Card Harness Current Defaults](../spikes/card-harness/CURRENT-DEFAULTS.md#finish-masks) as the material and finish reference. Its David and Timothy thresholds are fixture-specific starting points for evaluation, not one universal mask formula. Keep exact runtime parameters there. Preserve the approved holographic response when iterating on coverage.
|
||||||
|
|
||||||
# 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 Variants
|
||||||
|
|
||||||
Printing should affect the graphic presentation of the same card identity.
|
Printing should affect the graphic presentation of the same card identity.
|
||||||
|
|
||||||
## Normal
|
| Printing | Full-card art | Outer frame | Text backing | Text |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Normal | Yes | Yes | Yes | Yes |
|
||||||
|
| Borderless | Yes | No | Yes | Yes |
|
||||||
|
| Textless | Yes | Yes | No | No |
|
||||||
|
| Boundless | Yes | No | No | No |
|
||||||
|
|
||||||
- Standard frame.
|
Textless retains only the outer frame over the illustration. Borderless retains only the backing needed for legible text. Boundless presents the illustration alone. Educational information remains available in the inspection panel for every printing.
|
||||||
- Name.
|
|
||||||
- Subtitle.
|
|
||||||
- Short verse / quote / fact.
|
|
||||||
- Normal artwork area.
|
|
||||||
|
|
||||||
## Borderless
|
Artwork variant remains distinct from printing. A bespoke Legendary artwork variant may supply its own approved template, but its Normal, Borderless, Textless, and Boundless versions still follow these definitions.
|
||||||
|
|
||||||
- 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.
|
Printing type should not change the underlying rarity of the card.
|
||||||
|
|
||||||
@@ -775,7 +759,7 @@ Across all rarities:
|
|||||||
|
|
||||||
The distinction should be:
|
The distinction should be:
|
||||||
|
|
||||||
> **Common is simple. Legendary is elaborate.**
|
> **Common is simple. Legendary has exceptional presence.**
|
||||||
|
|
||||||
Not:
|
Not:
|
||||||
|
|
||||||
@@ -798,7 +782,6 @@ The MVP should establish:
|
|||||||
- Multiple materials.
|
- Multiple materials.
|
||||||
- Multiple finishes.
|
- Multiple finishes.
|
||||||
- Multiple printing types.
|
- Multiple printing types.
|
||||||
- Wear.
|
|
||||||
- 3D card inspection.
|
- 3D card inspection.
|
||||||
- Light-reactive materials.
|
- Light-reactive materials.
|
||||||
- Pack-opening presentation.
|
- Pack-opening presentation.
|
||||||
@@ -808,22 +791,28 @@ I think it makes more sense to create a smaller number of highly intentional art
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# First Proof and Deferred Work
|
||||||
|
|
||||||
|
Create a newly restrained Timothy illustration and one Common frame system, then assemble all four printings. Establish and validate the template geometry before generating final decoration. Review seams, text fit, focal placement, and exposed artwork at master size and application sizes, then evaluate the printings with the approved harness foil/holographic response. A small typography stress set should include a long name, a two-line title, and an excerpt near the budget limit.
|
||||||
|
|
||||||
|
The Trinity/Foundation art pass, wear/condition, and imperfections are deferred. They are outside this proof and M1 art requirements. Existing harness wear controls remain experimental tooling, not approval to add those properties to the product.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Open Art Questions
|
# Open Art Questions
|
||||||
|
|
||||||
Things that still need to be explored:
|
Things that still need to be explored:
|
||||||
|
|
||||||
- Exact card dimensions / aspect ratio.
|
|
||||||
- Final typography.
|
- Final typography.
|
||||||
|
- Exact template coordinates, optical text adjustments, minimum font sizes, and seam overlap after reviewing the first proof.
|
||||||
- Exact color palette.
|
- Exact color palette.
|
||||||
- Whether each rarity has a fixed base frame or a family of frames.
|
- Final subject-type vocabulary and quiet label placement, using the Master Card Catalog.
|
||||||
- Whether card categories have their own secondary visual motifs.
|
- Tradition-specific illustration and UI motifs within the shared rarity frame system.
|
||||||
- Whether tradition-specific cards alter UI or border accents.
|
|
||||||
- Exact visual treatment for controversies.
|
- Exact visual treatment for controversies.
|
||||||
- Whether halos should be used based on historical iconography, card category, tradition, or purely artistic composition.
|
- Whether halos should be used based on historical iconography, card category, tradition, or purely artistic composition.
|
||||||
- How much motion should exist inside holographic cards.
|
- How much motion should exist inside holographic cards.
|
||||||
- Exact distinction between Extraordinary and Legendary.
|
- Exact distinction between Extraordinary and Legendary.
|
||||||
- Whether higher-rarity cards are always visually more complex or occasionally gain prestige through extreme simplicity.
|
- Which Legendary artwork variants need bespoke templates or alternate compositions.
|
||||||
- How much the card artwork itself changes across printing variants.
|
|
||||||
- Final card back design.
|
- Final card back design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
55
docs/card-art-handoff.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Card Art Handoff — 2026-09-11
|
||||||
|
|
||||||
|
## Agreed Direction
|
||||||
|
|
||||||
|
- Stained glass is primary; tapestry/linen and illuminated-manuscript influences support it. Sacred, stylized, restrained, and readable.
|
||||||
|
- Full-card illustration, outer frame, text backing, and typeset text are separate authoring components. Common through Extraordinary share one frame system per tier; Legendary artwork variants may have bespoke art/layouts. Rarity, printing, artwork variant, substrate, and finish remain distinct.
|
||||||
|
- Common people appear in the middle distance doing an ordinary action. Use broad panes, restrained colors, simple geometry, and no gold. The original Timothy was too premium; the new proof is the preferred direction.
|
||||||
|
- Legendary should have exceptional presence: epic scale or contemplative beauty. More ornament or symbols are not mandatory. Rarity never implies spiritual worth.
|
||||||
|
- One quiet subject-type label belongs on text-bearing printings. Period, tradition, and History grouping belong in app metadata. Text must fit and remain readable; deeper content/sources belong in inspection.
|
||||||
|
- Trinity/Foundation artwork, wear/condition, and imperfections are deferred. Obsolete face/hand/lamb cutout guidance was removed; do not reintroduce it.
|
||||||
|
|
||||||
|
| Printing | Components |
|
||||||
|
|---|---|
|
||||||
|
| Normal | Art + frame + backing + text |
|
||||||
|
| Textless | Art + frame; no lettering |
|
||||||
|
| Borderless | Art + backing + text |
|
||||||
|
| Boundless | Art only |
|
||||||
|
|
||||||
|
## Implemented Timothy Proof
|
||||||
|
|
||||||
|
Folder: [spikes/timothy-layer-proof](../spikes/timothy-layer-proof/). The user said the result “looks amazing and proves the concept really well.” Small tweaks remain welcome.
|
||||||
|
|
||||||
|
- [Source illustration](../spikes/timothy-layer-proof/source/timothy-art.png): generated through the built-in image tool. Timothy reads a scroll beside a modest doorway with another person; blue/green/cream, broad stained-glass treatment. Native 1060 × 1484, uniformly enlarged to 2000 × 2800 without cropping.
|
||||||
|
- [card.json](../spikes/timothy-layer-proof/card.json): Timothy / Common / Person / “Faithful disciple”; full KJV 2 Timothy 4:7, including terminal colon. These are Paul's words to Timothy, not a quote spoken by Timothy. Source link and context are included.
|
||||||
|
- [layout.json](../spikes/timothy-layer-proof/layout.json): 5:7 canvas, 60 px frame, 12 px frame/backing overlap, fixed text/focal boxes. Noto Serif and Noto Sans typography.
|
||||||
|
- [build.mjs](../spikes/timothy-layer-proof/build.mjs): Inkscape exports vector/text layers; ImageMagick assembles cards and sizes; Node creates masks and checks. Both applications are installed. No GIMP/MCP was needed.
|
||||||
|
- [Review sheet](../spikes/timothy-layer-proof/output/review/printings.png), [editable SVG](../spikes/timothy-layer-proof/output/timothy-editable.svg), and [validation report](../spikes/timothy-layer-proof/output/validation.json). Runtime cards live in `output/cards/`; matching masks in `output/masks/`.
|
||||||
|
|
||||||
|
Rebuild from `/home/dkzver/docker`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node sanctification/spikes/timothy-layer-proof/build.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The script overwrites its generated outputs. Edit source inputs/build logic for reproducible tweaks; save manual SVG edits separately. It still hardcodes Timothy filenames, panel divider coordinates, and a one-line title. It is a proof, not a general card generator or two-line text layout engine.
|
||||||
|
|
||||||
|
## Corner Fix — Preserve This
|
||||||
|
|
||||||
|
The user saw pale corner specks in the harness. The original export had antialiased circular PNG corners, while the harness uses quadratic rounded geometry and an opaque front material. Lighter artwork mixed into the frame boundary could show through sampling.
|
||||||
|
|
||||||
|
Runtime exports now use fully opaque rectangles. `frame-runtime` and `normal-runtime-overlay` extend solid frame color into all outer corners; the 3D geometry determines the silhouette. Simply disabling alpha on the old rounded export was insufficient because pale RGB remained at its curved edge. Borderless/Boundless extend the illustration instead. Finish masks are rectangular data maps, with zero coverage beneath active frame/backing.
|
||||||
|
|
||||||
|
The rebuilt Normal/Textless corner samples are solid `#304747` with mask value zero. All four runtime masters passed opacity checks; text bounds, 99,200 overlay-join pixels, and repeated Normal composition passed. The user offered to perform visual validation; **post-fix harness confirmation has not yet been received**. The current review sheet uses rectangular runtime images; rounded authoring layers still exist, but separate rounded preview-card exports were not implemented.
|
||||||
|
|
||||||
|
## Masks, Docs, and Remaining Work
|
||||||
|
|
||||||
|
Timothy coverage starts with saturation `smoothstep(0.08,0.58)` × value `smoothstep(0.015,0.16)`, suppressed under active frame/backing. Frame and text panels are printed ink in this proof. Removed text panels must not leave obsolete protected regions in Textless/Boundless. Exact approved shader/material behavior remains in [CURRENT-DEFAULTS.md](../spikes/card-harness/CURRENT-DEFAULTS.md).
|
||||||
|
|
||||||
|
[Art Direction](./art-direction.md) was updated for the layered system, printing definitions, Common restraint, Legendary presence, concise typography/category, and deferrals. [Functional Spec](./functional-spec-v2.md) received matching deferred wording; its heading says v4. Harness README/defaults had obsolete cutout wording removed and experimental wear labeled deferred.
|
||||||
|
|
||||||
|
[Card Layer Pipeline](./card-layer-pipeline.md) is still a broader proposal: its opening and GIMP-first tooling text are stale, and not all proposed checks are implemented. Use this handoff and the actual build for current behavior. Font hashes are recorded, not enforced against a lockfile. The simple seam fixture does not constitute a comprehensive defect suite. Earlier interrupted attempts to add a proof README/prompt file did not land; the new [session prompt](./card-generation-session-prompt.md) captures the workflow for future generations.
|
||||||
|
|
||||||
|
Preserve the existing dirty worktree, including pre-existing deleted root `card-back.png`, `common.png`, and `legendary.png`. The proof has not replaced harness reference fixtures. For review, load a rebuilt card and its matching mask through Lab's local image inputs. Avoid changing shader defaults to compensate for an asset defect.
|
||||||
|
|
||||||
|
Environment note: tools repeatedly failed with `bwrap: loopback: Failed RTM_NEWADDR`. Approved shell escalation worked; the patch helper sometimes still failed when updating existing files. This is an environment issue, not a project dependency problem.
|
||||||
54
docs/card-generation-session-prompt.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Prompt for a New Card-Generation Session
|
||||||
|
|
||||||
|
Copy the block below into a session with access to this repository. Replace the bracketed brief; add one row per card. Start with another Common to exercise the existing reusable frame. Higher tiers still need their first approved templates.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create finished card assets for Sanctification using the layered process proven by Timothy.
|
||||||
|
|
||||||
|
Card brief:
|
||||||
|
- Subject/name: [name]
|
||||||
|
- Canonical rarity: [Common / Uncommon / Rare / Extraordinary / Legendary]
|
||||||
|
- Subject type: [Person / Event / Place / Object / Doctrine / other catalog type]
|
||||||
|
- Scene or governing idea: [one concrete sentence]
|
||||||
|
- Scripture/quote: [reference and translation; or ask you to propose suitable content]
|
||||||
|
- Optional subtitle: [short descriptor, or omit]
|
||||||
|
- Output folder: [new folder under sanctification/spikes/]
|
||||||
|
|
||||||
|
Read these before starting:
|
||||||
|
1. sanctification/docs/card-art-handoff.md
|
||||||
|
2. sanctification/docs/art-direction.md
|
||||||
|
3. Relevant sections of sanctification/docs/functional-spec-v2.md (its heading says v4)
|
||||||
|
4. sanctification/docs/card-layer-pipeline.md (a broader proposal; see handoff for implemented scope)
|
||||||
|
5. sanctification/spikes/timothy-layer-proof/{build.mjs,layout.json,card.json}
|
||||||
|
6. sanctification/spikes/card-harness/CURRENT-DEFAULTS.md
|
||||||
|
|
||||||
|
Inspect the existing Timothy source illustration and review sheet visually. Use them as references for Common restraint and composition, without copying Timothy's identity or scene. Follow applicable repository instructions and the available image-generation skill.
|
||||||
|
|
||||||
|
Use image generation for the base illustration and the installed Inkscape/ImageMagick tools for exact geometry, typography, composition, and export. Save the original generated source and exact generation prompt in the new card's folder. Do not install or switch to GIMP/MCP unless a concrete limitation requires discussion.
|
||||||
|
|
||||||
|
Art direction:
|
||||||
|
- Stained-glass-inspired, sacred, beautiful, and stylized; supporting manuscript/tapestry influence stays restrained.
|
||||||
|
- A Common uses broad panes, limited muted blue/green/cream colors, a simple shared geometric frame, and no gold. For people, show an ordinary action in the middle distance, with a clear focal subject and enough setting. Avoid headshots and heroic frontal posing.
|
||||||
|
- Higher rarity gains narrative presence, scale, beauty, light, and selective symbolism. Legendary may be epic or contemplative; avoid treating ornament density as a requirement.
|
||||||
|
- Common through Extraordinary share one frame system per tier. Reuse the existing Common geometry for Commons. Place subject-specific symbolism in the illustration. Legendary artwork variants may have bespoke templates.
|
||||||
|
|
||||||
|
Generate a full-bleed 5:7 illustration ONLY: no title, letters, border, backing panels, corner ornaments, or watermark. Compose against the template's art-safe region, keeping the focal subject clear of the Normal printing's overlays. The entire illustration must work when revealed by Textless or Boundless. Request generous real illustrated background behind overlay areas, not blank panel-shaped holes. Visually inspect the result before assembly.
|
||||||
|
|
||||||
|
Use a 2000 × 2800 working canvas. Retain the native source, record its dimensions and any uniform scale/crop, and do not call an upscale native high-resolution art. Keep every exported layer on the same canvas and origin.
|
||||||
|
|
||||||
|
Assemble all four printings from the same components:
|
||||||
|
- Normal: art + outer frame + text backing + text.
|
||||||
|
- Textless: art + outer frame; zero lettering or text backing.
|
||||||
|
- Borderless: art + text backing + text; no outer frame.
|
||||||
|
- Boundless: art alone.
|
||||||
|
|
||||||
|
Verify quoted wording and attribution, including who is speaking. Prefer a concise meaningful excerpt over crowded text. Typeset real editable text using explicit boxes, fonts, sizes, line spacing, and alignment. Measure unclipped glyph bounds; never silently shrink or clip overflowing text. Revise content or create an explicitly reviewed alternate layout. The existing script is Timothy-specific: adapt filenames and content assumptions deliberately instead of assuming it is a general batch engine.
|
||||||
|
|
||||||
|
Preserve the corner fix: runtime card faces are fully opaque rectangular textures. Framed printings extend solid frame color through the entire outer corners. Three.js geometry supplies the rounded silhouette. Do not apply the rounded silhouette alpha mask to runtime exports or merely disable alpha on an already clipped PNG. Borderless/Boundless carry full artwork into the corners.
|
||||||
|
|
||||||
|
Export a matching finish mask per printing. The Timothy proof uses saturation smoothstep(0.08,0.58) times value smoothstep(0.015,0.16), then suppresses coverage beneath active frame/backing layers. Treat that as a starting recipe to review for new art. Textless/Boundless must recover coverage in former text areas. Preserve existing harness shader settings.
|
||||||
|
|
||||||
|
Deliver the original artwork and prompt, content/source metadata, layout, reproducible build script, editable SVG, separate layer exports, four 2000 × 2800 runtime cards, 1000- and 500-pixel versions, matching finish masks, a labeled review sheet, and an honest validation report. Check text fit, frame/backing seams, opaque runtime pixels, solid frame corner colors, and zero finish coverage under framed corners. Inspect the review sheet yourself. I can do the final moving-light validation in the harness.
|
||||||
|
|
||||||
|
Keep new work in the requested output folder. Preserve existing proof/reference assets and unrelated working-tree changes. Summarize the outputs and any remaining visual decisions; do not claim human approval or runtime review that has not happened.
|
||||||
|
```
|
||||||
121
docs/card-layer-pipeline.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Card Layer Pipeline — First Proof Proposal
|
||||||
|
|
||||||
|
This is the proposed production and validation workflow for the layered card system in [Art Direction](./art-direction.md#layered-card-production). It is a design document, not an installed toolchain or a completed visual proof.
|
||||||
|
|
||||||
|
The first proof is a restrained Common Timothy illustration, one Common frame system, and four printings. Trinity/Foundation artwork, wear/condition, and imperfections are deferred.
|
||||||
|
|
||||||
|
## Geometry Owns Alignment
|
||||||
|
|
||||||
|
Use one versioned layout definition to produce the composition guides, frame/backing paths, transparency masks, text regions, and validation regions. Do not maintain separate hand-entered coordinates for each tool.
|
||||||
|
|
||||||
|
Generation supplies artwork and decorative surfaces. It does not decide final transparent boundaries, panel placement, lettering, or alignment. A guide image in a generation prompt helps composition but does not guarantee coordinate compliance. Fit and review the generated source once, then clip it to the template's exact masks.
|
||||||
|
|
||||||
|
For a simple Common frame, vector shapes and a restrained fill may be sufficient without generating any frame imagery. For more ornate tiers, generate decoration with extra coverage beyond the target paths, fit or retouch it once, and preserve the approved result. Clipping alone cannot repair an ornament whose structure is in the wrong place.
|
||||||
|
|
||||||
|
## Shared Canvas and Draft Coordinates
|
||||||
|
|
||||||
|
All production layers and masks use 2000 × 2800 pixels, origin at the top left, x increasing rightward and y downward. Preserve full-canvas transparent margins and place every exported layer at (0, 0). Use the renderer's established UV convention at the runtime boundary; do not independently mirror maps.
|
||||||
|
|
||||||
|
Generated source images may have other dimensions. Retain the original, record an explicit crop/scale transform into the production canvas, and visually review focal placement. Apply the same transform to any associated masks. Never stretch an illustration silently to fit.
|
||||||
|
|
||||||
|
The following coordinates are a **draft Common layout for the first proof**, not approved final proportions. Rectangles are x, y, width, height in master pixels. Outer rounded geometry should follow the harness's physical corner radius.
|
||||||
|
|
||||||
|
| Region | Draft geometry | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Canvas | 0, 0, 2000, 2800 | Full-card illustration and common origin |
|
||||||
|
| Outer frame | 60 px inset band | Simple continuous frame; transparent interior |
|
||||||
|
| Title backing | 48, 48, 1904, 312 | Extends 12 px underneath the frame at the top and sides |
|
||||||
|
| Lower backing | 48, 2180, 1904, 572 | Extends 12 px underneath the frame at the sides and bottom |
|
||||||
|
| Title text | 160, 105, 1680, 105 | One-line title box; horizontal center at x = 1000 |
|
||||||
|
| Subtitle | 160, 240, 1100, 64 | Optional short descriptor |
|
||||||
|
| Category | 1400, 240, 440, 64 | Quiet, right-aligned subject type |
|
||||||
|
| Focal safe region | 160, 430, 1680, 1680 | Essential subjects remain clear of Normal overlays |
|
||||||
|
| Excerpt / fact | 160, 2250, 1680, 280 | Short typeset supporting content |
|
||||||
|
| Reference | 160, 2580, 1680, 80 | Short attribution |
|
||||||
|
|
||||||
|
The backing's frame overlap is internal compositing allowance, not physical print bleed. For this draft, 12 master pixels becomes 3 pixels at 500 × 700. Review actual filter behavior before choosing a final overlap.
|
||||||
|
|
||||||
|
A two-line title needs a separately defined and reviewed layout with room for its subtitle/category; it is not permission to spill into the illustration. Each Legendary template needs the same explicit regions and checks even if its shapes differ.
|
||||||
|
|
||||||
|
## Seam-Free Border Assembly
|
||||||
|
|
||||||
|
1. Derive the outer frame and backing masks from shared vector paths.
|
||||||
|
2. Put a continuous, opaque base fill beneath decoration wherever the template requires solid coverage. Texture alpha must not accidentally punch through these regions.
|
||||||
|
3. Extend backing joins underneath the frame. Do not rely on two separately antialiased edges meeting at exactly one pixel boundary.
|
||||||
|
4. Place the frame over the backing, with one defined compositing order and color profile.
|
||||||
|
5. Export Normal's combined overlay, Textless's frame-only overlay, and Borderless's backing-only overlay. Boundless needs none.
|
||||||
|
6. Compose the final face at master resolution, then derive runtime sizes from that composition. Retain separate editable source layers.
|
||||||
|
|
||||||
|
Borderless backing must have clean standalone edges; it cannot depend on the absent outer frame to hide unfinished decoration. A joining surface also needs visual continuity in color and texture: an alpha check catches a gap, but cannot approve its appearance.
|
||||||
|
|
||||||
|
Keep a consistent alpha convention throughout export and resizing. Inspect light and dark backgrounds to catch edge fringes. A single composed front texture is sufficient for the standard runtime card; separate almost-coplanar 3D meshes are unnecessary for these authoring layers.
|
||||||
|
|
||||||
|
## Deterministic Text
|
||||||
|
|
||||||
|
Supply name, optional subtitle, subject type, excerpt, and attribution as structured content. Use real text objects with pinned font files, size, weight, line spacing, alignment, and language settings. Fix the rendering tool/version for reproducible exports.
|
||||||
|
|
||||||
|
For a centered title, the template supplies the center anchor x = 1000. The text engine measures and places the text around that anchor. Baselines and line spacing govern vertical placement. Record any approved optical adjustment rather than asking generation to center text.
|
||||||
|
|
||||||
|
Every template needs approved minimum font sizes and maximum line counts, established at actual application display sizes. Word count is an editorial guide, not a fit check. Long names and wide glyphs can overflow even with few words.
|
||||||
|
|
||||||
|
Validate the **unclipped rendered glyph bounds** against each text region and its padding. Checking only the text object's box can miss clipping. Reject missing fonts, missing glyphs, overflow, or insufficient line capacity; do not silently substitute fonts, crop the text, or shrink it below the minimum. Use a reviewed alternate layout or revise the content.
|
||||||
|
|
||||||
|
Text is rendered anew when card content changes, but its coordinates do not change randomly. Once approved, the frame/backing decoration is reused.
|
||||||
|
|
||||||
|
## Finish Coverage
|
||||||
|
|
||||||
|
Use the [approved harness defaults](../spikes/card-harness/CURRENT-DEFAULTS.md#finish-masks) as reference recipes. David and Timothy have distinct saturation thresholds; preserve the approved fixture behavior for comparisons.
|
||||||
|
|
||||||
|
Keep finish masks separate from transparency masks. For each printing:
|
||||||
|
|
||||||
|
- Use the active illustration, frame, and backing components' intended coverage.
|
||||||
|
- Resolve coverage using the same transforms, occlusion, and layer order as the visible face. Hidden illustration coverage must not leak onto a visible backing.
|
||||||
|
- Derive text protection from the actual layout.
|
||||||
|
- Remove protection associated with absent text/backing in Textless and Boundless.
|
||||||
|
- Export aligned scalar coverage maps; treat them as data at runtime rather than artwork color.
|
||||||
|
|
||||||
|
Review finish response in motion with the existing harness. A structurally valid mask still needs visual approval under several lighting angles. Keep exact shader coefficients in CURRENT-DEFAULTS.md.
|
||||||
|
|
||||||
|
## Proposed Automated Validation
|
||||||
|
|
||||||
|
Validation should run after fitting a generated source and after every final card assembly. A future build command should fail with a readable report and produce a diagnostic image showing the failed regions.
|
||||||
|
|
||||||
|
| Check | Evidence |
|
||||||
|
|---|---|
|
||||||
|
| Canvas and registration | All production layers/maps have the expected dimensions, origin, template revision, and recorded transforms |
|
||||||
|
| Required coverage | Combined overlay alpha has no gaps within the template's required opaque regions, including joins |
|
||||||
|
| Transparent windows | Pixels in required clear regions are transparent; intentional antialiased edge bands are checked separately |
|
||||||
|
| Typography | Required content exists, font/glyphs resolve, unclipped glyph bounds fit, line limits and minimum sizes pass |
|
||||||
|
| Printing composition | Normal, Textless, Borderless, and Boundless include exactly the prescribed components |
|
||||||
|
| Finish alignment | Coverage uses the same geometry/transforms and the correct printing's text protection |
|
||||||
|
| Downsampled edges | Approved runtime sizes have no exposed join pixels or unintended alpha fringes |
|
||||||
|
| Repeatability | Same content, assets, template, fonts, and pinned renderer reproduce the same decoded pixels |
|
||||||
|
|
||||||
|
Check overlay alpha independently of the final opaque illustration: a gap can reveal artwork while the finished card's alpha still looks completely opaque.
|
||||||
|
|
||||||
|
Use adversarial fixtures when implementing the validator: a displaced backing, a one-pixel seam, a missing font, an overlong title, and an obsolete text-protection region should fail. This verifies that the checks detect defects rather than merely accepting the sample card.
|
||||||
|
|
||||||
|
Automated checks cannot guarantee narrative focus, artistic quality, optical balance, or perceived text readability under moving light. Produce review sheets at master, standard, and thumbnail sizes, plus harness views. A diagnostic outline of text and focal regions is useful for review but is never exported on the finished card.
|
||||||
|
|
||||||
|
## Tooling Proposal
|
||||||
|
|
||||||
|
A practical starting stack is GIMP for editable assembly and review, an SVG template for geometry and typography, and a small command-line validation/export script. Do not require all tools if one renderer can meet the contract.
|
||||||
|
|
||||||
|
- **GIMP:** editable layers/masks and optional scripted assembly. Its API supports creating text layers and controlling their fonts, sizing, justification, and spacing. See the official [TextLayer API](https://developer.gimp.org/api/3.0/libgimp/class.TextLayer.html).
|
||||||
|
- **Inkscape/SVG:** author exact paths, render a fixed page, and query object bounds for layout checks. See the official [command-line guide](https://wiki.inkscape.org/wiki/Using_the_Command_Line). Validate unclipped glyph bounds as well as object boxes.
|
||||||
|
- **ImageMagick:** optional alpha compositing and pixel inspection for exported images. See its [alpha-compositing documentation](https://imagemagick.org/compose/). It provides primitives, not a ready-made card validator.
|
||||||
|
- **MCP:** an optional interface to invoke the same deterministic assembly and validation operations. A connector alone does not guarantee alignment, and freeform visual placement by an agent should not replace the shared layout definition.
|
||||||
|
|
||||||
|
No GIMP MCP is connected in this session. Select and verify a specific adapter's compatibility with the installed GIMP version before depending on it. A CLI pipeline can implement the same workflow without that adapter.
|
||||||
|
|
||||||
|
## Proof Sequence
|
||||||
|
|
||||||
|
1. Render the template with plain fills and a typography stress set. Establish acceptable proportions, font sizes, and overlap.
|
||||||
|
2. Generate the restrained Timothy illustration using the approved guide, then fit and review it once.
|
||||||
|
3. Add restrained Common frame decoration only if plain vector treatment is insufficient.
|
||||||
|
4. Assemble all four printings and their aligned finish masks.
|
||||||
|
5. Run structural checks and review seams and text at target sizes.
|
||||||
|
6. Review with the approved harness finish response and compare against the existing reference fixture.
|
||||||
|
7. Freeze the approved template/assets and reuse them for subsequent Commons.
|
||||||
|
|
||||||
|
Keep the current harness reference fixtures available for comparison. This proof should establish new production assets without silently replacing the approved material baseline.
|
||||||
@@ -573,7 +573,7 @@ Card Instance
|
|||||||
Opened on <date>
|
Opened on <date>
|
||||||
```
|
```
|
||||||
|
|
||||||
Randomized wear/condition and random imperfections are **not** part of the card-instance model.
|
Randomized wear/condition and random imperfections are **deferred** and are not part of the M1 card-instance model.
|
||||||
|
|
||||||
That general contract should stay renderer-independent. The database should not care whether Three.js or some future engine is displaying the card.
|
That general contract should stay renderer-independent. The database should not care whether Three.js or some future engine is displaying the card.
|
||||||
|
|
||||||
@@ -614,7 +614,7 @@ The exact visual treatment, physical appearance, compatibility constraints, thic
|
|||||||
|
|
||||||
### Wear / Condition
|
### Wear / Condition
|
||||||
|
|
||||||
I am cutting randomized wear / condition from the card-instance model entirely.
|
Randomized wear / condition is deferred and excluded from the M1 card-instance model.
|
||||||
|
|
||||||
The more I thought about it, the more it felt like negative variance rather than a fun collector property. Getting the Legendary you wanted in the finish, material, and printing you wanted only for it to be a "bad" damaged copy would feel like being punished for no reason - especially when packs are earned through habits rather than something you can endlessly buy or grind that same day.
|
The more I thought about it, the more it felt like negative variance rather than a fun collector property. Getting the Legendary you wanted in the finish, material, and printing you wanted only for it to be a "bad" damaged copy would feel like being punished for no reason - especially when packs are earned through habits rather than something you can endlessly buy or grind that same day.
|
||||||
|
|
||||||
@@ -634,7 +634,7 @@ Eventually there could still be some fun digital-scale idea for unopened packs,
|
|||||||
|
|
||||||
### Imperfections
|
### Imperfections
|
||||||
|
|
||||||
Cut.
|
Deferred; excluded from the M1 card-instance model.
|
||||||
|
|
||||||
I do not want randomized fingerprints, hairs, print defects, or other accidental-looking imperfections as an instance-level collectible system.
|
I do not want randomized fingerprints, hairs, print defects, or other accidental-looking imperfections as an instance-level collectible system.
|
||||||
|
|
||||||
@@ -814,7 +814,7 @@ At the functional level I only want a few religious-art guardrails:
|
|||||||
* Historical/traditional iconography can inform the artwork where appropriate.
|
* Historical/traditional iconography can inform the artwork where appropriate.
|
||||||
* Rarity should never imply greater spiritual worth.
|
* Rarity should never imply greater spiritual worth.
|
||||||
|
|
||||||
The Trinity/Foundation card remains a special art case that needs its own pass.
|
The Trinity/Foundation art pass remains deferred.
|
||||||
|
|
||||||
I still expect to lean heavily on LLM/image-generation tooling and MCP servers for creating and iterating on the artwork since I am a software engineer, not an artist.
|
I still expect to lean heavily on LLM/image-generation tooling and MCP servers for creating and iterating on the artwork since I am a software engineer, not an artist.
|
||||||
|
|
||||||
@@ -1007,7 +1007,7 @@ I would rather have **50 cards where all of the difficult rendering/material/var
|
|||||||
|
|
||||||
The full catalog can still contain 500 identities. M1 only needs a representative subset actually implemented.
|
The full catalog can still contain 500 identities. M1 only needs a representative subset actually implemented.
|
||||||
|
|
||||||
Randomized wear/condition and randomized imperfections are cut from the design. Filler-card economics, multiplayer trading, and advanced social behavior are not required for M1.
|
Randomized wear/condition and randomized imperfections are deferred. Filler-card economics, multiplayer trading, and advanced social behavior are not required for M1.
|
||||||
|
|
||||||
### Not Required for M1
|
### Not Required for M1
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ Changing a light type, color, position, intensity, environment contribution, or
|
|||||||
| Control | Default | Available range |
|
| Control | Default | Available range |
|
||||||
|---|---:|---:|
|
|---|---:|---:|
|
||||||
| Finish | Holographic | Printed ink / Foil / Holographic |
|
| Finish | Holographic | Printed ink / Foil / Holographic |
|
||||||
| Material | Paper | Paper / Linen / Plastic / Metal / Wood |
|
| Material | Paper | Paper / Linen / Metal / Wood |
|
||||||
| Finish strength | 0.60 | 0-1 |
|
| Finish strength | 0.60 | 0-1 |
|
||||||
| Roughness | 0.23 | 0.05-0.8 |
|
| Roughness | 0.23 | 0.05-0.8 |
|
||||||
| Surface detail | 0.14 | 0-0.45 |
|
| Surface detail | 0.14 | 0-0.45 |
|
||||||
@@ -231,7 +231,6 @@ mask follows the shifted artwork. Card thickness and geometry are unchanged.
|
|||||||
- Saturation threshold: `smoothstep(0.18, 0.62)`
|
- Saturation threshold: `smoothstep(0.18, 0.62)`
|
||||||
- Value threshold: `smoothstep(0.015, 0.16)`
|
- Value threshold: `smoothstep(0.015, 0.16)`
|
||||||
- Retains soft title-panel protection.
|
- Retains soft title-panel protection.
|
||||||
- Does not use circular or elliptical exclusions over the face, hand, or lamb.
|
|
||||||
|
|
||||||
### Timothy
|
### Timothy
|
||||||
|
|
||||||
@@ -242,11 +241,12 @@ All finish masks should:
|
|||||||
|
|
||||||
- Protect critical text unless a treatment is intentionally designed for it.
|
- 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.
|
- 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.
|
- Be reviewed while the card rotates under multiple lighting angles.
|
||||||
|
|
||||||
## Wear and Imperfections
|
## Wear and Imperfections
|
||||||
|
|
||||||
|
Wear/condition and imperfections are deferred product features. These settings document existing experimental harness behavior, not M1 requirements.
|
||||||
|
|
||||||
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.
|
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.
|
- Edge wear uses seeded irregular width and substrate-appropriate exposed colors.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFA
|
|||||||
## Included proof scope
|
## Included proof scope
|
||||||
|
|
||||||
- David printed-ink, foil, and holographic presets
|
- David printed-ink, foil, and holographic presets
|
||||||
- Independently selectable paper, linen, plastic, metal, and wood substrates
|
- Independently selectable paper, linen, metal, and wood substrates
|
||||||
- Timothy as the second-artwork fixture
|
- 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
|
- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy
|
||||||
- Procedural rounded-card geometry generated from shared physical dimensions
|
- Procedural rounded-card geometry generated from shared physical dimensions
|
||||||
- Deterministic linen normal map
|
- Deterministic linen normal map
|
||||||
- Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting
|
- Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting
|
||||||
@@ -18,7 +18,7 @@ The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFA
|
|||||||
- Live light color, position, intensity, environment contribution, and exposure controls
|
- Live light color, position, intensity, environment contribution, and exposure controls
|
||||||
- Lab-mode loading of local artwork and finish-mask images without rebuilding
|
- Lab-mode loading of local artwork and finish-mask images without rebuilding
|
||||||
- Named browser experiment saves, versioned JSON import/export, and PNG capture
|
- Named browser experiment saves, versioned JSON import/export, and PNG capture
|
||||||
- Deterministic condition wear with substrate-aware edges, scratches, scuffs, and imperfection seeds
|
- Experimental condition wear with substrate-aware edges, scratches, scuffs, and imperfection seeds; wear and imperfections are deferred product features
|
||||||
- Bare Inspect mode and small Lab-mode parameter panel
|
- 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
|
- 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
|
- Drawing-buffer resolution plus median, p95, and worst frame-time display
|
||||||
@@ -28,6 +28,21 @@ Transform gizmos and a general timeline are intentionally deferred. The separate
|
|||||||
## Tear-open foil pack prototype
|
## Tear-open foil pack prototype
|
||||||
|
|
||||||
Choose **Pack** in the Mode selector (expand **Controls** first on mobile).
|
Choose **Pack** in the Mode selector (expand **Controls** first on mobile).
|
||||||
|
Use **Pack style** in the same toolbar to compare three wrapper-only design studies:
|
||||||
|
|
||||||
|
- **Cathedral glass:** deep blue, geometric stained-glass window artwork, and restrained gold.
|
||||||
|
- **Illuminated manuscript:** warm ivory, burgundy, and botanical manuscript linework.
|
||||||
|
- **Quiet modern** (initial selection): dark green, generous space, and a small sacred emblem.
|
||||||
|
|
||||||
|
All three use the same wording, foil material, pouch geometry, lighting, card contents,
|
||||||
|
and opening animation. These are alternative visual studies, not different pack tiers
|
||||||
|
or reward odds. Switching styles changes the front, back, and matching tear strip in
|
||||||
|
place, preserving the current tear/inspection state. Use **Restart** to compare sealed
|
||||||
|
packs; the chosen style is retained across Restart and mode switches, but not a page
|
||||||
|
reload. On mobile, reopen **Controls** after entering Pack to access the selector.
|
||||||
|
The six front/back textures are generated once and uploaded during pack preparation,
|
||||||
|
then reused for instant switching. No illustration service or external asset is needed.
|
||||||
|
|
||||||
Drag the gold top seam **to the right**, or use **Tear open** for the same authored opening without dragging:
|
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.**
|
**Sealed → progressive tear → strip curls away → mouth spreads → staggered card extraction → face-down stack → lifting → lifted → revealing → inspecting → advancing → next stack / complete.**
|
||||||
@@ -105,8 +120,8 @@ silhouette, back, or thickness. This linen look was visually approved September
|
|||||||
2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in
|
2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in
|
||||||
[`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration
|
[`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration
|
||||||
is required: selecting Linen applies it in Inspect, Lab, and the authored pack.
|
is required: selecting Linen applies it in Inspect, Lab, and the authored pack.
|
||||||
Plastic has a smooth clear-coat
|
The legacy Plastic shader recipe remains readable by older saved experiments but is
|
||||||
highlight even in Printed ink mode. Wood uses raised longitudinal grain with
|
not offered in the UI or randomized packs. Wood uses raised longitudinal grain with
|
||||||
bounded procedural parallax, stronger relief, and a restrained warm tint.
|
bounded procedural parallax, stronger relief, and a restrained warm tint.
|
||||||
The artwork stays anchored while grain shading and tint shift together.
|
The artwork stays anchored while grain shading and tint shift together.
|
||||||
Metal retains its reflective underprint and uses luminance-driven recesses:
|
Metal retains its reflective underprint and uses luminance-driven recesses:
|
||||||
@@ -136,7 +151,7 @@ asset manifest therefore retains its v4 reference revision; the scoped material
|
|||||||
approvals and parameters are tracked in `CURRENT-DEFAULTS.md`.
|
approvals and parameters are tracked in `CURRENT-DEFAULTS.md`.
|
||||||
|
|
||||||
The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output.
|
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.
|
David uses color-based coverage and title-panel protection. Holographic color and angle response are preserved; reduced sheen applies only to foil.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"assets": "node scripts/generate-reference-assets.mjs",
|
"assets": "node scripts/generate-reference-assets.mjs",
|
||||||
|
"catalog": "node scripts/card-catalog.mjs",
|
||||||
"export:blender": "node scripts/export-card-mesh.mjs",
|
"export:blender": "node scripts/export-card-mesh.mjs",
|
||||||
"test": "node --test scripts/*.test.mjs",
|
"test": "node --test scripts/*.test.mjs",
|
||||||
"predev": "npm run assets",
|
"predev": "npm run assets",
|
||||||
|
|||||||
BIN
spikes/card-harness/public/card-art/David-mask.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
spikes/card-harness/public/card-art/David.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
21
spikes/card-harness/public/card-art/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Card Art Drop Folder
|
||||||
|
|
||||||
|
Add each card as one PNG artwork and one matching PNG finish mask:
|
||||||
|
|
||||||
|
```text
|
||||||
|
david.png
|
||||||
|
david-mask.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The filename before `.png` is the card name shown in the harness. Names are
|
||||||
|
case-sensitive. Artwork and mask must:
|
||||||
|
|
||||||
|
- have identical dimensions;
|
||||||
|
- use a 5:7 aspect ratio;
|
||||||
|
- be fully opaque;
|
||||||
|
- use an opaque grayscale mask.
|
||||||
|
|
||||||
|
During `npm run dev`, adding or replacing either PNG refreshes the searchable
|
||||||
|
card catalog automatically. Reopening or focusing the Card field also forces a
|
||||||
|
rescan. Production builds generate `manifest.json` from this folder and fail if
|
||||||
|
any pair is missing or invalid.
|
||||||
BIN
spikes/card-harness/public/card-art/Timothy-mask.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
spikes/card-harness/public/card-art/Timothy.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
22
spikes/card-harness/public/card-art/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"name": "David",
|
||||||
|
"artwork": "/card-art/David.png?v=a5541f0b4b68e13e",
|
||||||
|
"mask": "/card-art/David-mask.png?v=a5541f0b4b68e13e",
|
||||||
|
"revision": "a5541f0b4b68e13e",
|
||||||
|
"width": 1060,
|
||||||
|
"height": 1484
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Timothy",
|
||||||
|
"artwork": "/card-art/Timothy.png?v=1118b083057106d4",
|
||||||
|
"mask": "/card-art/Timothy-mask.png?v=1118b083057106d4",
|
||||||
|
"revision": "1118b083057106d4",
|
||||||
|
"width": 1060,
|
||||||
|
"height": 1484
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
BIN
spikes/card-harness/public/reference/card-back-old.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
spikes/card-harness/public/reference/card-back-white.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.7 MiB |
111
spikes/card-harness/scripts/card-catalog.mjs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { mkdir, readFile, readdir, 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))
|
||||||
|
export const harnessRoot = resolve(here, '..')
|
||||||
|
export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art')
|
||||||
|
export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json')
|
||||||
|
|
||||||
|
function parsePng(name, bytes) {
|
||||||
|
try {
|
||||||
|
return PNG.sync.read(bytes)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePair(name, artworkBytes, maskBytes) {
|
||||||
|
const artwork = parsePng(`${name}.png`, artworkBytes)
|
||||||
|
const mask = parsePng(`${name}-mask.png`, maskBytes)
|
||||||
|
if (artwork.width * 7 !== artwork.height * 5) {
|
||||||
|
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`)
|
||||||
|
}
|
||||||
|
if (mask.width !== artwork.width || mask.height !== artwork.height) {
|
||||||
|
throw new Error(
|
||||||
|
`${name}-mask.png must match ${name}.png dimensions; found ` +
|
||||||
|
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < artwork.data.length; offset += 4) {
|
||||||
|
if (artwork.data[offset + 3] !== 255) {
|
||||||
|
throw new Error(`${name}.png must be fully opaque for the runtime card surface`)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mask.data[offset] !== mask.data[offset + 1] ||
|
||||||
|
mask.data[offset] !== mask.data[offset + 2] ||
|
||||||
|
mask.data[offset + 3] !== 255
|
||||||
|
) {
|
||||||
|
throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width: artwork.width, height: artwork.height }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanCardCatalog(root = cardArtRoot) {
|
||||||
|
await mkdir(root, { recursive: true })
|
||||||
|
const files = (await readdir(root, { withFileTypes: true }))
|
||||||
|
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
|
||||||
|
.map(entry => entry.name)
|
||||||
|
const fileSet = new Set(files)
|
||||||
|
const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png'))
|
||||||
|
const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png'))
|
||||||
|
const cards = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (const artworkFile of artworkFiles) {
|
||||||
|
const name = artworkFile.slice(0, -4)
|
||||||
|
const maskFile = `${name}-mask.png`
|
||||||
|
if (!fileSet.has(maskFile)) {
|
||||||
|
errors.push(`Missing ${maskFile} for ${artworkFile}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [artworkBytes, maskBytes] = await Promise.all([
|
||||||
|
readFile(resolve(root, artworkFile)),
|
||||||
|
readFile(resolve(root, maskFile)),
|
||||||
|
])
|
||||||
|
const dimensions = validatePair(name, artworkBytes, maskBytes)
|
||||||
|
const revision = createHash('sha256')
|
||||||
|
.update(artworkBytes)
|
||||||
|
.update(maskBytes)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
cards.push({
|
||||||
|
name,
|
||||||
|
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`,
|
||||||
|
mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`,
|
||||||
|
revision,
|
||||||
|
...dimensions,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const maskFile of maskFiles) {
|
||||||
|
const artworkFile = `${maskFile.slice(0, -9)}.png`
|
||||||
|
if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||||
|
errors.sort()
|
||||||
|
return { schemaVersion: 1, cards, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) {
|
||||||
|
const catalog = await scanCardCatalog(root)
|
||||||
|
await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`)
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const catalog = await writeCardCatalog()
|
||||||
|
console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
|
||||||
|
if (catalog.errors.length) {
|
||||||
|
for (const error of catalog.errors) console.error(`- ${error}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
47
spikes/card-harness/scripts/card-catalog.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { PNG } from 'pngjs'
|
||||||
|
import { scanCardCatalog } from './card-catalog.mjs'
|
||||||
|
|
||||||
|
function png(width, height, pixel) {
|
||||||
|
const image = new PNG({ width, height })
|
||||||
|
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||||
|
image.data.set(pixel, offset)
|
||||||
|
}
|
||||||
|
return PNG.sync.write(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('catalog pairs filename-based card art with validated masks', async (t) => {
|
||||||
|
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-'))
|
||||||
|
t.after(() => rm(root, { recursive: true }))
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(resolve(root, 'david.png'), png(10, 14, [20, 40, 60, 255])),
|
||||||
|
writeFile(resolve(root, 'david-mask.png'), png(10, 14, [128, 128, 128, 255])),
|
||||||
|
])
|
||||||
|
const catalog = await scanCardCatalog(root)
|
||||||
|
assert.deepEqual(catalog.errors, [])
|
||||||
|
assert.equal(catalog.cards.length, 1)
|
||||||
|
assert.equal(catalog.cards[0].name, 'david')
|
||||||
|
assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/)
|
||||||
|
assert.equal(catalog.cards[0].width, 10)
|
||||||
|
assert.equal(catalog.cards[0].height, 14)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('catalog reports missing pairs and invalid runtime images explicitly', async (t) => {
|
||||||
|
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-'))
|
||||||
|
t.after(() => rm(root, { recursive: true }))
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])),
|
||||||
|
writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])),
|
||||||
|
writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])),
|
||||||
|
writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])),
|
||||||
|
])
|
||||||
|
const catalog = await scanCardCatalog(root)
|
||||||
|
assert.deepEqual(catalog.cards, [])
|
||||||
|
assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png')))
|
||||||
|
assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png')))
|
||||||
|
assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque')))
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import { installDOM, TestCanvas, geometryHash, wrapperCases } from './test-suppo
|
|||||||
|
|
||||||
const { createFoilWrapper } = await import('../src/foilWrapper.ts')
|
const { createFoilWrapper } = await import('../src/foilWrapper.ts')
|
||||||
const { PackOpening } = await import('../src/packOpening.ts')
|
const { PackOpening } = await import('../src/packOpening.ts')
|
||||||
|
const { packStyles, defaultPackStyle, createPackTexture } = await import('../src/packDesigns.ts')
|
||||||
|
|
||||||
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
|
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
|
||||||
const originalHashes = [
|
const originalHashes = [
|
||||||
@@ -56,6 +57,49 @@ test('only changed surfaces recompute normals, bounds and upload buffers', (t) =
|
|||||||
assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
|
assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('three cached pack designs change all printed surfaces without changing geometry or materials', () => {
|
||||||
|
installDOM()
|
||||||
|
const wrapper = createFoilWrapper()
|
||||||
|
assert.equal(packStyles.length, 3)
|
||||||
|
assert.equal(wrapper.style, defaultPackStyle)
|
||||||
|
assert.equal(wrapper.textures.length, 6)
|
||||||
|
const materials = wrapper.root.children.map(mesh => mesh.material)
|
||||||
|
const maps = new Set()
|
||||||
|
for (const style of packStyles) {
|
||||||
|
wrapper.setStyle(style)
|
||||||
|
assert.equal(wrapper.style, style)
|
||||||
|
const [front, back, strip] = wrapper.root.children.map(mesh => mesh.material.map)
|
||||||
|
assert.equal(front, strip, 'ribbon must use the same printed sheet as the front')
|
||||||
|
assert.notEqual(front, back)
|
||||||
|
maps.add(front)
|
||||||
|
maps.add(back)
|
||||||
|
for (const texture of [front, back]) {
|
||||||
|
assert.equal(texture.image.width, 1024)
|
||||||
|
assert.equal(texture.image.height, 1400)
|
||||||
|
assert.equal(texture.colorSpace, THREE.SRGBColorSpace)
|
||||||
|
}
|
||||||
|
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material), materials)
|
||||||
|
wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => {
|
||||||
|
wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit)
|
||||||
|
assert.equal(geometryHash(wrapper), originalHashes[index], `${style}, pose ${index}`)
|
||||||
|
})
|
||||||
|
wrapper.setStyle(style)
|
||||||
|
assert.equal(wrapper.root.children[0].material.map, front, 'selecting the same style reuses its texture')
|
||||||
|
}
|
||||||
|
assert.equal(maps.size, 6)
|
||||||
|
const before = wrapper.root.children.map(mesh => mesh.material.map)
|
||||||
|
assert.throws(() => wrapper.setStyle('unknown'), /Unknown pack style/)
|
||||||
|
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material.map), before)
|
||||||
|
wrapper.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pack textures reject invalid styles and unavailable drawing contexts', (t) => {
|
||||||
|
installDOM()
|
||||||
|
assert.throws(() => createPackTexture('unknown'))
|
||||||
|
t.mock.method(TestCanvas.prototype, 'getContext', () => null)
|
||||||
|
assert.throws(() => createPackTexture(defaultPackStyle))
|
||||||
|
})
|
||||||
|
|
||||||
function setupPack(t, reducedMotion = false) {
|
function setupPack(t, reducedMotion = false) {
|
||||||
installDOM(reducedMotion)
|
installDOM(reducedMotion)
|
||||||
let now = 1000
|
let now = 1000
|
||||||
@@ -158,6 +202,39 @@ test('interruption and mode changes preserve progress without deforming in point
|
|||||||
assert.equal(pack.state, 'stackReady')
|
assert.equal(pack.state, 'stackReady')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('style switching preserves partial tears, card order, restart selection and retained mode state', (t) => {
|
||||||
|
const { pack, canvas, step } = setupPack(t)
|
||||||
|
let reveals = 0
|
||||||
|
canvas.addEventListener('packreveal', () => { reveals++ })
|
||||||
|
pack.primary()
|
||||||
|
step(600)
|
||||||
|
pack.pointerDown(pointer(1, 0, 0))
|
||||||
|
pack.pointerUp(pointer(1, 0, 0))
|
||||||
|
const progress = pack.tearProgress
|
||||||
|
const geometry = geometryHash(pack.wrapper)
|
||||||
|
const cardSpecs = pack.cards.map(card => card.material.uuid)
|
||||||
|
for (const style of packStyles) {
|
||||||
|
pack.setStyle(style)
|
||||||
|
assert.equal(pack.style, style)
|
||||||
|
assert.equal(pack.tearProgress, progress)
|
||||||
|
assert.equal(pack.paused, true)
|
||||||
|
assert.equal(pack.state, 'opening')
|
||||||
|
assert.equal(geometryHash(pack.wrapper), geometry)
|
||||||
|
assert.deepEqual(pack.cards.map(card => card.material.uuid), cardSpecs)
|
||||||
|
assert.equal(reveals, 0)
|
||||||
|
}
|
||||||
|
pack.setStyle(packStyles[0])
|
||||||
|
pack.setActive(false)
|
||||||
|
pack.setActive(true)
|
||||||
|
assert.equal(pack.style, packStyles[0])
|
||||||
|
assert.equal(pack.tearProgress, progress)
|
||||||
|
pack.restart()
|
||||||
|
assert.equal(pack.style, packStyles[0])
|
||||||
|
assert.equal(pack.state, 'sealed')
|
||||||
|
assert.equal(pack.tearProgress, 0)
|
||||||
|
pack.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
|
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
|
||||||
const { pack, canvas, step } = setupPack(t)
|
const { pack, canvas, step } = setupPack(t)
|
||||||
const seam = pack.seamScreenBounds()
|
const seam = pack.seamScreenBounds()
|
||||||
@@ -267,7 +344,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
|
|||||||
finishCompile()
|
finishCompile()
|
||||||
await preparation
|
await preparation
|
||||||
assert.equal(uploaded.length, new Set(uploaded).size)
|
assert.equal(uploaded.length, new Set(uploaded).size)
|
||||||
assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps')
|
assert.equal(uploaded.length, 13, '4 artwork/mask textures, normal, environment, back and 6 wrapper maps')
|
||||||
for (const { artwork, mask } of Object.values(pack.options.textures)) {
|
for (const { artwork, mask } of Object.values(pack.options.textures)) {
|
||||||
assert.ok(uploaded.includes(artwork))
|
assert.ok(uploaded.includes(artwork))
|
||||||
assert.ok(uploaded.includes(mask))
|
assert.ok(uploaded.includes(mask))
|
||||||
@@ -275,6 +352,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
|
|||||||
assert.ok(uploaded.includes(backTexture))
|
assert.ok(uploaded.includes(backTexture))
|
||||||
assert.ok(uploaded.includes(pack.options.normalMap))
|
assert.ok(uploaded.includes(pack.options.normalMap))
|
||||||
assert.ok(uploaded.includes(scene.environment))
|
assert.ok(uploaded.includes(scene.environment))
|
||||||
|
for (const texture of pack.wrapper.textures) assert.ok(uploaded.includes(texture), 'inactive pack styles must also be GPU-ready')
|
||||||
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
|
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
|
||||||
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
|
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
|
||||||
assert.equal(geometryHash(pack.wrapper), before)
|
assert.equal(geometryHash(pack.wrapper), before)
|
||||||
@@ -333,8 +411,7 @@ test('pack cleanup disposes owned resources without disposing shared or caller-o
|
|||||||
materials.add(object.material)
|
materials.add(object.material)
|
||||||
})
|
})
|
||||||
materials.delete(pack.options.backMaterial)
|
materials.delete(pack.options.backMaterial)
|
||||||
const resources = [...geometries, ...materials,
|
const resources = [...geometries, ...materials, ...pack.wrapper.textures]
|
||||||
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
|
|
||||||
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
|
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
|
||||||
pack.dispose()
|
pack.dispose()
|
||||||
assert.equal(pack.root.parent, null)
|
assert.equal(pack.root.parent, null)
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export class TestCanvas extends EventTarget {
|
|||||||
return {
|
return {
|
||||||
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
|
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
|
||||||
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
|
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
|
||||||
|
beginPath() {}, closePath() {}, moveTo() {}, lineTo() {}, bezierCurveTo() {},
|
||||||
|
quadraticCurveTo() {}, arc() {}, ellipse() {}, rect() {}, roundRect() {},
|
||||||
|
fill() {}, stroke() {}, clip() {}, scale() {}, setLineDash() {},
|
||||||
|
measureText(text) { return { width: text.length * 22 } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getBoundingClientRect() { return { left: 0, top: 0, width: this.width, height: this.height } }
|
getBoundingClientRect() { return { left: 0, top: 0, width: this.width, height: this.height } }
|
||||||
|
|||||||
60
spikes/card-harness/src/cardCatalog.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export interface CardAsset {
|
||||||
|
name: string
|
||||||
|
artwork: string
|
||||||
|
mask: string
|
||||||
|
revision: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardCatalog {
|
||||||
|
schemaVersion: 1
|
||||||
|
cards: CardAsset[]
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCardAsset(value: unknown): value is CardAsset {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const card = value as Record<string, unknown>
|
||||||
|
return typeof card.name === 'string' && card.name.length > 0 &&
|
||||||
|
typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') &&
|
||||||
|
typeof card.mask === 'string' && card.mask.startsWith('/card-art/') &&
|
||||||
|
typeof card.revision === 'string' &&
|
||||||
|
Number.isInteger(card.width) && Number.isInteger(card.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCardCatalog(): Promise<CardCatalog> {
|
||||||
|
const urls = import.meta.env.DEV
|
||||||
|
? ['/__card_catalog', '/card-art/manifest.json']
|
||||||
|
: ['/card-art/manifest.json']
|
||||||
|
const failures: string[] = []
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { cache: 'no-store' })
|
||||||
|
if (!response.ok) throw new Error(`request failed (${response.status})`)
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
throw new Error(`expected JSON but received ${contentType || 'an unknown content type'}`)
|
||||||
|
}
|
||||||
|
return validateCardCatalog(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Card catalog unavailable: ${failures.join('; ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCardCatalog(value: unknown): CardCatalog {
|
||||||
|
if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object')
|
||||||
|
const catalog = value as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
catalog.schemaVersion !== 1 ||
|
||||||
|
!Array.isArray(catalog.cards) ||
|
||||||
|
!catalog.cards.every(isCardAsset) ||
|
||||||
|
!Array.isArray(catalog.errors) ||
|
||||||
|
!catalog.errors.every(error => typeof error === 'string')
|
||||||
|
) {
|
||||||
|
throw new Error('Card catalog has an unsupported or invalid format')
|
||||||
|
}
|
||||||
|
return catalog as unknown as CardCatalog
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { createPackTexture, defaultPackStyle, packStyles, type PackStyle } from './packDesigns'
|
||||||
|
|
||||||
const halfWidth = 1.68
|
const halfWidth = 1.68
|
||||||
const bottom = -2.24
|
const bottom = -2.24
|
||||||
@@ -7,50 +8,6 @@ const top = 2.28
|
|||||||
const midDepth = -0.09
|
const midDepth = -0.09
|
||||||
const smooth = THREE.MathUtils.smoothstep
|
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.
|
* 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.
|
* All deformation is evaluated from immutable UVs, not accumulated frame deltas.
|
||||||
@@ -58,13 +15,37 @@ function wrapperTexture(back = false) {
|
|||||||
export function createFoilWrapper() {
|
export function createFoilWrapper() {
|
||||||
const root = new THREE.Group()
|
const root = new THREE.Group()
|
||||||
root.name = 'PACK_FOIL_WRAPPER'
|
root.name = 'PACK_FOIL_WRAPPER'
|
||||||
const frontTexture = wrapperTexture()
|
const designs = new Map<PackStyle, { front: THREE.CanvasTexture; back: THREE.CanvasTexture }>()
|
||||||
|
const textures: THREE.Texture[] = []
|
||||||
|
try {
|
||||||
|
for (const style of packStyles) {
|
||||||
|
const front = createPackTexture(style)
|
||||||
|
textures.push(front)
|
||||||
|
const back = createPackTexture(style, true)
|
||||||
|
textures.push(back)
|
||||||
|
designs.set(style, { front, back })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
for (const texture of textures) texture.dispose()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
let activeStyle = defaultPackStyle
|
||||||
|
const initialDesign = designs.get(activeStyle)!
|
||||||
const frontMaterial = new THREE.MeshStandardMaterial({
|
const frontMaterial = new THREE.MeshStandardMaterial({
|
||||||
map: frontTexture, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
|
map: initialDesign.front, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
|
||||||
})
|
})
|
||||||
const backMaterial = frontMaterial.clone()
|
const backMaterial = frontMaterial.clone()
|
||||||
backMaterial.map = wrapperTexture(true)
|
backMaterial.map = initialDesign.back
|
||||||
const stripMaterial = frontMaterial.clone()
|
const stripMaterial = frontMaterial.clone()
|
||||||
|
function setStyle(style: PackStyle) {
|
||||||
|
const design = designs.get(style)
|
||||||
|
if (!design) throw new Error(`Unknown pack style: ${style}`)
|
||||||
|
if (style === activeStyle) return
|
||||||
|
frontMaterial.map = design.front
|
||||||
|
backMaterial.map = design.back
|
||||||
|
stripMaterial.map = design.front
|
||||||
|
activeStyle = style
|
||||||
|
}
|
||||||
const sheets = [1, -1].map((side) => {
|
const sheets = [1, -1].map((side) => {
|
||||||
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
|
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
|
||||||
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
|
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
|
||||||
@@ -176,9 +157,8 @@ export function createFoilWrapper() {
|
|||||||
function dispose() {
|
function dispose() {
|
||||||
for (const { mesh } of sheets) mesh.geometry.dispose()
|
for (const { mesh } of sheets) mesh.geometry.dispose()
|
||||||
stripGeometry.dispose()
|
stripGeometry.dispose()
|
||||||
frontTexture.dispose()
|
for (const texture of textures) texture.dispose()
|
||||||
backMaterial.map!.dispose()
|
|
||||||
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
|
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
|
||||||
}
|
}
|
||||||
return { root, deform, dispose }
|
return { root, deform, dispose, setStyle, textures: Object.freeze(textures), get style() { return activeStyle } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
type SubstrateName,
|
type SubstrateName,
|
||||||
} from './cardMaterial'
|
} from './cardMaterial'
|
||||||
import { createCardGeometry } from './cardGeometry'
|
import { createCardGeometry } from './cardGeometry'
|
||||||
import { PackOpening, type PackFixture } from './packOpening'
|
import { PackOpening, packSize, rollPackContents } from './packOpening'
|
||||||
|
import { defaultPackStyle, packStyles } from './packDesigns'
|
||||||
|
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog'
|
||||||
|
|
||||||
type FixtureName = PackFixture
|
type FixtureName = string
|
||||||
type AppMode = 'Inspect' | 'Lab' | 'Pack'
|
type AppMode = 'Inspect' | 'Lab' | 'Pack'
|
||||||
type ManipulationTarget = 'Card' | 'Camera'
|
type ManipulationTarget = 'Card' | 'Camera'
|
||||||
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
|
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
|
||||||
@@ -50,16 +52,10 @@ interface ExperimentSnapshot {
|
|||||||
orbitTarget: [number, number, number]
|
orbitTarget: [number, number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixtureAssets: Record<FixtureName, { artwork: string; mask: string }> = {
|
let cardCatalog = await fetchCardCatalog()
|
||||||
David: {
|
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs')
|
||||||
artwork: '/reference/david-front.png',
|
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card]))
|
||||||
mask: '/reference/david-finish-mask.png',
|
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name
|
||||||
},
|
|
||||||
Timothy: {
|
|
||||||
artwork: '/reference/timothy-front.png',
|
|
||||||
mask: '/reference/timothy-finish-mask.png',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
|
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
|
||||||
texture.colorSpace = colorSpace
|
texture.colorSpace = colorSpace
|
||||||
@@ -93,12 +89,16 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<option>Pack</option>
|
<option>Pack</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Card
|
<label class="pack-style">Pack style
|
||||||
<select id="fixture">
|
<select id="pack-style" title="Change the wrapper only; use Restart to compare sealed packs" disabled>
|
||||||
<option>David</option>
|
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
|
||||||
<option>Timothy</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Card
|
||||||
|
<span class="catalog-state" id="catalog-state" aria-live="polite"></span>
|
||||||
|
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false">
|
||||||
|
<datalist id="fixture-options"></datalist>
|
||||||
|
</label>
|
||||||
<label>Finish
|
<label>Finish
|
||||||
<select id="finish">
|
<select id="finish">
|
||||||
<option>Holographic</option>
|
<option>Holographic</option>
|
||||||
@@ -110,7 +110,6 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<select id="substrate">
|
<select id="substrate">
|
||||||
<option>Paper</option>
|
<option>Paper</option>
|
||||||
<option>Linen</option>
|
<option>Linen</option>
|
||||||
<option>Plastic</option>
|
|
||||||
<option>Metal</option>
|
<option>Metal</option>
|
||||||
<option>Wood</option>
|
<option>Wood</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -159,7 +158,7 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<span id="pack-status" role="status" aria-live="polite"></span>
|
<span id="pack-status" role="status" aria-live="polite"></span>
|
||||||
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
|
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
|
||||||
<div class="pack-actions">
|
<div class="pack-actions">
|
||||||
<button id="pack-restart" type="button">Restart</button>
|
<button id="pack-restart" type="button">New pack</button>
|
||||||
<button id="pack-primary" type="button">Open pack</button>
|
<button id="pack-primary" type="button">Open pack</button>
|
||||||
<button id="pack-skip" type="button">Skip</button>
|
<button id="pack-skip" type="button">Skip</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +183,9 @@ const hintElement = document.querySelector<HTMLDivElement>('#hint')!
|
|||||||
const topbar = document.querySelector<HTMLElement>('.topbar')!
|
const topbar = document.querySelector<HTMLElement>('.topbar')!
|
||||||
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
|
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
|
||||||
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
|
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
|
||||||
const fixtureSelect = document.querySelector<HTMLSelectElement>('#fixture')!
|
const fixtureSelect = document.querySelector<HTMLInputElement>('#fixture')!
|
||||||
|
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')!
|
||||||
|
const catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
|
||||||
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
|
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
|
||||||
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
|
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
|
||||||
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
|
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
|
||||||
@@ -202,6 +203,23 @@ const packTearProgress = document.querySelector<HTMLProgressElement>('#pack-tear
|
|||||||
const packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
|
const packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
|
||||||
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
|
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
|
||||||
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
|
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
|
||||||
|
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
|
||||||
|
|
||||||
|
function renderCardCatalog(catalog: CardCatalog) {
|
||||||
|
fixtureOptions.replaceChildren(...catalog.cards.map(card => {
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.value = card.name
|
||||||
|
return option
|
||||||
|
}))
|
||||||
|
catalogStateElement.textContent = catalog.errors.length
|
||||||
|
? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
|
||||||
|
: `${catalog.cards.length} cards`
|
||||||
|
catalogStateElement.classList.toggle('has-errors', catalog.errors.length > 0)
|
||||||
|
catalogStateElement.title = catalog.errors.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCardCatalog(cardCatalog)
|
||||||
|
fixtureSelect.value = defaultFixture
|
||||||
|
|
||||||
function setControlsCollapsed(collapsed: boolean) {
|
function setControlsCollapsed(collapsed: boolean) {
|
||||||
topbar.classList.toggle('controls-collapsed', collapsed)
|
topbar.classList.toggle('controls-collapsed', collapsed)
|
||||||
@@ -270,8 +288,9 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
|
|||||||
backTexture.colorSpace = THREE.SRGBColorSpace
|
backTexture.colorSpace = THREE.SRGBColorSpace
|
||||||
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
|
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
|
||||||
|
|
||||||
const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork)
|
const initialAsset = fixtureAssets.get(defaultFixture)!
|
||||||
const initialMask = await loader.loadAsync(fixtureAssets.David.mask)
|
const initialArtwork = await loader.loadAsync(initialAsset.artwork)
|
||||||
|
const initialMask = await loader.loadAsync(initialAsset.mask)
|
||||||
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
|
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
|
||||||
configureFrontTexture(initialMask, THREE.NoColorSpace)
|
configureFrontTexture(initialMask, THREE.NoColorSpace)
|
||||||
|
|
||||||
@@ -413,7 +432,7 @@ surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).na
|
|||||||
updateMaterial()
|
updateMaterial()
|
||||||
updateStatus()
|
updateStatus()
|
||||||
})
|
})
|
||||||
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
|
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
substrateSelect.value = value
|
substrateSelect.value = value
|
||||||
updateMaterial()
|
updateMaterial()
|
||||||
@@ -515,7 +534,7 @@ const assetActions = {
|
|||||||
loadMask: () => maskFileInput.click(),
|
loadMask: () => maskFileInput.click(),
|
||||||
restoreFixture: () => {
|
restoreFixture: () => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
void loadFixture(fixtureSelect.value as FixtureName)
|
void loadFixture(fixtureSelect.value)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const assetFolder = gui.addFolder('Assets')
|
const assetFolder = gui.addFolder('Assets')
|
||||||
@@ -546,7 +565,7 @@ gui.hide()
|
|||||||
|
|
||||||
let mode: AppMode = 'Inspect'
|
let mode: AppMode = 'Inspect'
|
||||||
let textureRequest = 0
|
let textureRequest = 0
|
||||||
let activeFixture: FixtureName = 'David'
|
let activeFixture: FixtureName = defaultFixture
|
||||||
let customArtworkName: string | undefined
|
let customArtworkName: string | undefined
|
||||||
let customMaskName: string | undefined
|
let customMaskName: string | undefined
|
||||||
let dragging = false
|
let dragging = false
|
||||||
@@ -567,6 +586,8 @@ let pack: PackOpening | undefined
|
|||||||
let packLoading = false
|
let packLoading = false
|
||||||
let packUIDirty = false
|
let packUIDirty = false
|
||||||
let packError: string | undefined
|
let packError: string | undefined
|
||||||
|
let packTextures: THREE.Texture[] = []
|
||||||
|
let selectedPackStyle = defaultPackStyle
|
||||||
|
|
||||||
function updateMaterial() {
|
function updateMaterial() {
|
||||||
applyMaterialControls(frontMaterial, controls)
|
applyMaterialControls(frontMaterial, controls)
|
||||||
@@ -696,12 +717,16 @@ function updatePackUI() {
|
|||||||
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
||||||
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
||||||
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
|
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
|
||||||
setIfChanged(packRestartButton, 'disabled', !pack)
|
setIfChanged(packRestartButton, 'disabled', packLoading || fixtureAssets.size === 0)
|
||||||
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
|
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
|
||||||
|
setIfChanged(packRestartButton, 'textContent', 'New pack')
|
||||||
|
setIfChanged(packStyleSelect, 'disabled', !pack || packLoading)
|
||||||
|
setIfChanged(packStyleSelect, 'value', pack?.style ?? defaultPackStyle)
|
||||||
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
|
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
|
||||||
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
||||||
setIfChanged(packStatusElement, 'textContent', status)
|
setIfChanged(packStatusElement, 'textContent', status)
|
||||||
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
|
setIfChanged(statusElement, 'textContent',
|
||||||
|
`Random foil pack · ${packSize} cards · equal card / material / finish odds`)
|
||||||
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
|
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,8 +734,8 @@ function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
|
|||||||
if (target[key] !== value) target[key] = value
|
if (target[key] !== value) target[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function preparePack() {
|
async function preparePack(newRoll = false) {
|
||||||
if (pack) {
|
if (pack && !newRoll) {
|
||||||
pack.syncLighting(frontMaterial)
|
pack.syncLighting(frontMaterial)
|
||||||
pack.setActive(true)
|
pack.setActive(true)
|
||||||
updatePackUI()
|
updatePackUI()
|
||||||
@@ -719,13 +744,26 @@ async function preparePack() {
|
|||||||
if (packLoading) return
|
if (packLoading) return
|
||||||
packLoading = true
|
packLoading = true
|
||||||
packError = undefined
|
packError = undefined
|
||||||
|
if (newRoll && pack) {
|
||||||
|
selectedPackStyle = pack.style
|
||||||
|
pack.dispose()
|
||||||
|
pack = undefined
|
||||||
|
for (const texture of packTextures) texture.dispose()
|
||||||
|
packTextures = []
|
||||||
|
}
|
||||||
updatePackUI()
|
updatePackUI()
|
||||||
let preparedPack: PackOpening | undefined
|
let preparedPack: PackOpening | undefined
|
||||||
let textures: THREE.Texture[] = []
|
let textures: THREE.Texture[] = []
|
||||||
try {
|
try {
|
||||||
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
|
const contents = rollPackContents([...fixtureAssets.keys()])
|
||||||
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
|
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
|
||||||
fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask]
|
const selectedAssets = selectedNames.map(name => {
|
||||||
|
const asset = fixtureAssets.get(name)
|
||||||
|
if (!asset) throw new Error(`Card "${name}" disappeared from the catalog while rolling`)
|
||||||
|
return asset
|
||||||
|
})
|
||||||
|
// Only cards selected for this pack are loaded; a large catalog does not consume GPU memory.
|
||||||
|
const paths = selectedAssets.flatMap(asset => [asset.artwork, asset.mask])
|
||||||
const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
|
const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
|
||||||
const failure = results.find((result) => result.status === 'rejected')
|
const failure = results.find((result) => result.status === 'rejected')
|
||||||
if (failure?.status === 'rejected') {
|
if (failure?.status === 'rejected') {
|
||||||
@@ -739,18 +777,21 @@ async function preparePack() {
|
|||||||
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
|
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
|
||||||
return result.value
|
return result.value
|
||||||
})
|
})
|
||||||
|
const selectedTextures = new Map(selectedAssets.map((asset, index) => [
|
||||||
|
asset.name,
|
||||||
|
{ artwork: textures[index * 2], mask: textures[index * 2 + 1] },
|
||||||
|
]))
|
||||||
preparedPack = new PackOpening({
|
preparedPack = new PackOpening({
|
||||||
canvas,
|
canvas,
|
||||||
textures: {
|
contents,
|
||||||
David: { artwork: textures[0], mask: textures[1] },
|
textures: selectedTextures,
|
||||||
Timothy: { artwork: textures[2], mask: textures[3] },
|
|
||||||
},
|
|
||||||
backMaterial,
|
backMaterial,
|
||||||
normalMap,
|
normalMap,
|
||||||
environment: activeEnvironment,
|
environment: activeEnvironment,
|
||||||
lightPosition,
|
lightPosition,
|
||||||
onChange: () => { packUIDirty = true },
|
onChange: () => { packUIDirty = true },
|
||||||
})
|
})
|
||||||
|
preparedPack.setStyle(selectedPackStyle)
|
||||||
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
|
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
|
||||||
let preparedEnvironment: THREE.Texture | null
|
let preparedEnvironment: THREE.Texture | null
|
||||||
let preparedLightType: LightType
|
let preparedLightType: LightType
|
||||||
@@ -765,6 +806,7 @@ async function preparePack() {
|
|||||||
preparedPack.setActive(mode === 'Pack')
|
preparedPack.setActive(mode === 'Pack')
|
||||||
scene.add(preparedPack.root)
|
scene.add(preparedPack.root)
|
||||||
pack = preparedPack
|
pack = preparedPack
|
||||||
|
packTextures = textures
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
preparedPack?.dispose()
|
preparedPack?.dispose()
|
||||||
for (const texture of textures) texture.dispose()
|
for (const texture of textures) texture.dispose()
|
||||||
@@ -789,7 +831,7 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
|
|||||||
function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
|
function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
|
||||||
if (typeof value !== 'object' || value === null) return false
|
if (typeof value !== 'object' || value === null) return false
|
||||||
const snapshot = value as Record<string, unknown>
|
const snapshot = value as Record<string, unknown>
|
||||||
const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy'
|
const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
|
||||||
const finishValid =
|
const finishValid =
|
||||||
snapshot.finish === 'Printed ink' ||
|
snapshot.finish === 'Printed ink' ||
|
||||||
snapshot.finish === 'Foil' ||
|
snapshot.finish === 'Foil' ||
|
||||||
@@ -986,6 +1028,9 @@ function captureExperimentPng() {
|
|||||||
|
|
||||||
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
|
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
|
if (!fixtureAssets.has(snapshot.fixture)) {
|
||||||
|
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
|
||||||
|
}
|
||||||
fixtureSelect.value = snapshot.fixture
|
fixtureSelect.value = snapshot.fixture
|
||||||
await loadFixture(snapshot.fixture)
|
await loadFixture(snapshot.fixture)
|
||||||
controls.finish = snapshot.finish
|
controls.finish = snapshot.finish
|
||||||
@@ -1072,7 +1117,8 @@ function applyInspectionPose(pose: InspectionPose) {
|
|||||||
|
|
||||||
function resetView() {
|
function resetView() {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined
|
const resetFixture = activeFixture !== defaultFixture ||
|
||||||
|
customArtworkName !== undefined || customMaskName !== undefined
|
||||||
rotationTarget.set(-0.06, 0.12, 0)
|
rotationTarget.set(-0.06, 0.12, 0)
|
||||||
cardRoot.rotation.copy(rotationTarget)
|
cardRoot.rotation.copy(rotationTarget)
|
||||||
camera.position.set(0, 0, 8.2)
|
camera.position.set(0, 0, 8.2)
|
||||||
@@ -1080,7 +1126,7 @@ function resetView() {
|
|||||||
orbit.update()
|
orbit.update()
|
||||||
modeSelect.value = 'Inspect'
|
modeSelect.value = 'Inspect'
|
||||||
controls.target = 'Card'
|
controls.target = 'Card'
|
||||||
fixtureSelect.value = 'David'
|
fixtureSelect.value = defaultFixture
|
||||||
finishSelect.value = 'Holographic'
|
finishSelect.value = 'Holographic'
|
||||||
substrateSelect.value = 'Paper'
|
substrateSelect.value = 'Paper'
|
||||||
poseSelect.value = 'Front'
|
poseSelect.value = 'Front'
|
||||||
@@ -1096,7 +1142,7 @@ function resetView() {
|
|||||||
updateEdgeMaterial()
|
updateEdgeMaterial()
|
||||||
updateMode()
|
updateMode()
|
||||||
if (resetFixture) {
|
if (resetFixture) {
|
||||||
void loadFixture('David')
|
void loadFixture(defaultFixture)
|
||||||
} else {
|
} else {
|
||||||
updateStatus()
|
updateStatus()
|
||||||
}
|
}
|
||||||
@@ -1107,7 +1153,13 @@ async function loadFixture(nextFixture: FixtureName) {
|
|||||||
const request = ++textureRequest
|
const request = ++textureRequest
|
||||||
loadingElement.hidden = false
|
loadingElement.hidden = false
|
||||||
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
|
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
|
||||||
const asset = fixtureAssets[nextFixture]
|
const asset = fixtureAssets.get(nextFixture)
|
||||||
|
if (!asset) {
|
||||||
|
loadingElement.hidden = true
|
||||||
|
fixtureSelect.value = activeFixture
|
||||||
|
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
@@ -1119,6 +1171,7 @@ async function loadFixture(nextFixture: FixtureName) {
|
|||||||
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
|
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
|
||||||
throw artworkResult.reason
|
throw artworkResult.reason
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maskResult.status === 'rejected') {
|
if (maskResult.status === 'rejected') {
|
||||||
artworkResult.value.dispose()
|
artworkResult.value.dispose()
|
||||||
throw maskResult.reason
|
throw maskResult.reason
|
||||||
@@ -1157,6 +1210,45 @@ async function loadFixture(nextFixture: FixtureName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveFixtureName(value: string) {
|
||||||
|
if (fixtureAssets.has(value)) return value
|
||||||
|
const normalized = value.toLocaleLowerCase()
|
||||||
|
return [...fixtureAssets.keys()].find(name => name.toLocaleLowerCase() === normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCardCatalog() {
|
||||||
|
try {
|
||||||
|
const nextCatalog = await fetchCardCatalog()
|
||||||
|
if (!nextCatalog.cards.length) {
|
||||||
|
throw new Error(nextCatalog.errors.join('; ') || 'No valid artwork/mask pairs found')
|
||||||
|
}
|
||||||
|
const previousAsset: CardAsset | undefined = fixtureAssets.get(activeFixture)
|
||||||
|
const nextAssets = new Map(nextCatalog.cards.map(card => [card.name, card]))
|
||||||
|
const nextAsset = nextAssets.get(activeFixture)
|
||||||
|
cardCatalog = nextCatalog
|
||||||
|
fixtureAssets = nextAssets
|
||||||
|
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
|
||||||
|
renderCardCatalog(nextCatalog)
|
||||||
|
if (nextCatalog.errors.length) {
|
||||||
|
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
nextAsset &&
|
||||||
|
previousAsset?.revision !== nextAsset.revision &&
|
||||||
|
customArtworkName === undefined &&
|
||||||
|
customMaskName === undefined
|
||||||
|
) {
|
||||||
|
await loadFixture(activeFixture)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
catalogStateElement.textContent = 'Catalog error'
|
||||||
|
catalogStateElement.classList.add('has-errors')
|
||||||
|
catalogStateElement.title = message
|
||||||
|
statusElement.textContent = `Could not refresh card catalog: ${message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
|
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
|
||||||
const request = ++textureRequest
|
const request = ++textureRequest
|
||||||
const objectUrl = URL.createObjectURL(file)
|
const objectUrl = URL.createObjectURL(file)
|
||||||
@@ -1331,17 +1423,46 @@ packPrimaryButton.addEventListener('click', () => {
|
|||||||
if (pack) pack.primary()
|
if (pack) pack.primary()
|
||||||
else void preparePack()
|
else void preparePack()
|
||||||
})
|
})
|
||||||
packRestartButton.addEventListener('click', () => pack?.restart())
|
packRestartButton.addEventListener('click', () => { void preparePack(true) })
|
||||||
packSkipButton.addEventListener('click', () => pack?.skip())
|
packSkipButton.addEventListener('click', () => pack?.skip())
|
||||||
|
packStyleSelect.addEventListener('change', () => {
|
||||||
|
try {
|
||||||
|
const style = packStyles.find(style => style === packStyleSelect.value)
|
||||||
|
if (!style || !pack) throw new Error('The selected pack style is unavailable')
|
||||||
|
selectedPackStyle = style
|
||||||
|
pack.setStyle(style)
|
||||||
|
updatePackUI()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not change pack style', error)
|
||||||
|
packStyleSelect.value = pack?.style ?? defaultPackStyle
|
||||||
|
packStatusElement.textContent = `Pack style unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
modeSelect.addEventListener('change', () => {
|
modeSelect.addEventListener('change', () => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
updateMode()
|
updateMode()
|
||||||
})
|
})
|
||||||
fixtureSelect.addEventListener('change', () => {
|
function selectFixtureFromPicker() {
|
||||||
|
const fixture = resolveFixtureName(fixtureSelect.value.trim())
|
||||||
|
if (!fixture) {
|
||||||
|
statusElement.textContent = `No card named "${fixtureSelect.value.trim()}" exists in the card-art folder.`
|
||||||
|
fixtureSelect.value = activeFixture
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fixtureSelect.value = fixture
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
void loadFixture(fixtureSelect.value as FixtureName)
|
void loadFixture(fixture)
|
||||||
|
}
|
||||||
|
fixtureSelect.addEventListener('change', selectFixtureFromPicker)
|
||||||
|
fixtureSelect.addEventListener('focus', () => { void refreshCardCatalog() })
|
||||||
|
fixtureSelect.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Enter') selectFixtureFromPicker()
|
||||||
})
|
})
|
||||||
|
window.addEventListener('focus', () => { void refreshCardCatalog() })
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
|
||||||
|
}
|
||||||
substrateSelect.addEventListener('change', () => {
|
substrateSelect.addEventListener('change', () => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
controls.substrate = substrateSelect.value as SubstrateName
|
controls.substrate = substrateSelect.value as SubstrateName
|
||||||
|
|||||||
365
spikes/card-harness/src/packDesigns.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
export const packStyles = ['Cathedral glass', 'Illuminated manuscript', 'Quiet modern'] as const
|
||||||
|
export type PackStyle = typeof packStyles[number]
|
||||||
|
export const defaultPackStyle: PackStyle = 'Quiet modern'
|
||||||
|
|
||||||
|
type Ink = CanvasRenderingContext2D
|
||||||
|
type Point = readonly [number, number]
|
||||||
|
|
||||||
|
const width = 1024
|
||||||
|
const height = 1400
|
||||||
|
const gold = '#c7a365'
|
||||||
|
const serif = 'Georgia, "Times New Roman", serif'
|
||||||
|
const sans = 'Arial, Helvetica, sans-serif'
|
||||||
|
|
||||||
|
function line(ctx: Ink, points: readonly Point[], color: string, weight = 2) {
|
||||||
|
ctx.beginPath()
|
||||||
|
points.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = weight
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygon(ctx: Ink, points: readonly Point[], fill: string, stroke?: string, weight = 3) {
|
||||||
|
ctx.beginPath()
|
||||||
|
points.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fillStyle = fill
|
||||||
|
ctx.fill()
|
||||||
|
if (stroke) {
|
||||||
|
ctx.strokeStyle = stroke
|
||||||
|
ctx.lineWidth = weight
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function circle(ctx: Ink, x: number, y: number, radius: number, color: string, weight = 2) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2)
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = weight
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure tracking explicitly so every label stays inside its designated print area.
|
||||||
|
function text(ctx: Ink, label: string, x: number, y: number, size: number,
|
||||||
|
color: string, family = sans, tracking = 0, maxWidth = 860, weight = 'normal') {
|
||||||
|
ctx.save()
|
||||||
|
ctx.font = `${weight} ${size}px ${family}`
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
const letters = Array.from(label)
|
||||||
|
const measured = letters.reduce((sum, letter) => sum + ctx.measureText(letter).width, 0)
|
||||||
|
+ Math.max(0, letters.length - 1) * tracking
|
||||||
|
const scale = Math.min(1, maxWidth / measured)
|
||||||
|
ctx.translate(x, y)
|
||||||
|
ctx.scale(scale, 1)
|
||||||
|
let cursor = -measured / 2
|
||||||
|
for (const letter of letters) {
|
||||||
|
ctx.fillText(letter, cursor, 0)
|
||||||
|
cursor += ctx.measureText(letter).width + tracking
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(ctx: Ink, x: number, y: number, size: number, color: string, weight = 3) {
|
||||||
|
line(ctx, [[x, y - size], [x, y + size]], color, weight)
|
||||||
|
line(ctx, [[x - size * 0.64, y - size * 0.3], [x + size * 0.64, y - size * 0.3]], color, weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
function diamond(ctx: Ink, x: number, y: number, size: number, color: string) {
|
||||||
|
polygon(ctx, [[x, y - size], [x + size, y], [x, y + size], [x - size, y]], color)
|
||||||
|
}
|
||||||
|
|
||||||
|
function footer(ctx: Ink, back: boolean, color: string, y = 1195) {
|
||||||
|
text(ctx, back ? 'AUTHORED EDITION / 001' : 'ONE COLLECTION',
|
||||||
|
512, y, 22, color, sans, 3, 790)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointedWindow(ctx: Ink, x: number, top: number, w: number, h: number) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x - w / 2, top + h)
|
||||||
|
ctx.lineTo(x - w / 2, top + h * 0.43)
|
||||||
|
ctx.bezierCurveTo(x - w / 2, top + h * 0.2, x - w * 0.17, top + h * 0.06, x, top)
|
||||||
|
ctx.bezierCurveTo(x + w * 0.17, top + h * 0.06, x + w / 2, top + h * 0.2, x + w / 2, top + h * 0.43)
|
||||||
|
ctx.lineTo(x + w / 2, top + h)
|
||||||
|
ctx.closePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
function glass(ctx: Ink, x: number, top: number, w: number, h: number) {
|
||||||
|
const lead = '#0b1b36'
|
||||||
|
const pane = ['#204e77', '#347690', '#23385e', '#435a83', '#749597', '#315977']
|
||||||
|
ctx.save()
|
||||||
|
pointedWindow(ctx, x, top, w, h)
|
||||||
|
ctx.clip()
|
||||||
|
ctx.fillStyle = '#173858'
|
||||||
|
ctx.fillRect(x - w / 2, top, w, h)
|
||||||
|
|
||||||
|
const centerY = top + h * 0.35
|
||||||
|
const radius = w * 0.33
|
||||||
|
// Radiating glass tesserae surround a twelve-petal rose and three lancets.
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const a = i * Math.PI / 12 - Math.PI / 2
|
||||||
|
const b = (i + 1) * Math.PI / 12 - Math.PI / 2
|
||||||
|
polygon(ctx, [
|
||||||
|
[x + Math.cos(a) * radius, centerY + Math.sin(a) * radius],
|
||||||
|
[x + Math.cos(a) * w * 1.6, centerY + Math.sin(a) * w * 1.6],
|
||||||
|
[x + Math.cos(b) * w * 1.6, centerY + Math.sin(b) * w * 1.6],
|
||||||
|
[x + Math.cos(b) * radius, centerY + Math.sin(b) * radius],
|
||||||
|
], pane[i % pane.length]!, lead, 6)
|
||||||
|
}
|
||||||
|
circle(ctx, x, centerY, radius + 7, gold, 3)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(x, centerY)
|
||||||
|
ctx.rotate(i * Math.PI / 6)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, -radius)
|
||||||
|
ctx.quadraticCurveTo(radius * 0.4, -radius * 0.52, 0, -radius * 0.2)
|
||||||
|
ctx.quadraticCurveTo(-radius * 0.4, -radius * 0.52, 0, -radius)
|
||||||
|
ctx.fillStyle = pane[(i + 1) % pane.length]!
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = lead
|
||||||
|
ctx.lineWidth = 5
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
circle(ctx, x, centerY, radius * 0.21, gold, 3)
|
||||||
|
cross(ctx, x, centerY, radius * 0.13, '#e1cf99', 4)
|
||||||
|
|
||||||
|
const lancetTop = top + h * 0.62
|
||||||
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
const lx = x + i * w * 0.27
|
||||||
|
const ly = lancetTop + Math.abs(i) * 26
|
||||||
|
const lw = w * 0.21
|
||||||
|
const lh = top + h - ly - 20
|
||||||
|
pointedWindow(ctx, lx, ly, lw, lh)
|
||||||
|
ctx.fillStyle = i === 0 ? '#346b82' : '#1c3c61'
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = lead
|
||||||
|
ctx.lineWidth = 8
|
||||||
|
ctx.stroke()
|
||||||
|
pointedWindow(ctx, lx, ly + 10, lw - 14, lh - 17)
|
||||||
|
ctx.strokeStyle = gold
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
for (let y = ly + 66; y < top + h - 45; y += 67) {
|
||||||
|
polygon(ctx, [[lx, y - 24], [lx + lw * 0.3, y], [lx, y + 24], [lx - lw * 0.3, y]],
|
||||||
|
i === 0 ? '#90a49c' : '#416c88', lead, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
pointedWindow(ctx, x, top, w, h)
|
||||||
|
ctx.strokeStyle = gold
|
||||||
|
ctx.lineWidth = 5
|
||||||
|
ctx.stroke()
|
||||||
|
pointedWindow(ctx, x, top - 17, w + 30, h + 30)
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cathedral(ctx: Ink, back: boolean) {
|
||||||
|
const ivory = '#e6ddc5'
|
||||||
|
ctx.fillStyle = '#101e3a'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
ctx.strokeStyle = '#344767'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.strokeRect(77, 165, 870, 1080)
|
||||||
|
for (const x of [92, 932]) {
|
||||||
|
line(ctx, [[x, 185], [x, 1225]], gold, 1.5)
|
||||||
|
for (const y of [190, 1220]) diamond(ctx, x, y, 5, gold)
|
||||||
|
}
|
||||||
|
text(ctx, 'SANCTIFICATION', 512, 231, back ? 48 : 61, ivory, serif, 1.2, 814)
|
||||||
|
text(ctx, 'COLLECTOR SERIES', 512, 288, 21, gold, sans, 5)
|
||||||
|
if (back) {
|
||||||
|
glass(ctx, 512, 435, 392, 575)
|
||||||
|
line(ctx, [[217, 680], [217, 1040], [402, 1040]], '#42577a')
|
||||||
|
line(ctx, [[807, 680], [807, 1040], [622, 1040]], '#42577a')
|
||||||
|
diamond(ctx, 217, 648, 7, gold)
|
||||||
|
diamond(ctx, 807, 648, 7, gold)
|
||||||
|
} else {
|
||||||
|
glass(ctx, 512, 358, 626, 688)
|
||||||
|
}
|
||||||
|
line(ctx, [[260, 1127], [468, 1127]], gold, 1.5)
|
||||||
|
diamond(ctx, 512, 1127, 7, gold)
|
||||||
|
line(ctx, [[556, 1127], [764, 1127]], gold, 1.5)
|
||||||
|
footer(ctx, back, ivory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaf(ctx: Ink, x: number, y: number, angle: number, size: number, fill: string, ink: string) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(x, y)
|
||||||
|
ctx.rotate(angle)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, 0)
|
||||||
|
ctx.bezierCurveTo(-size * 0.55, -size * 0.3, -size * 0.4, -size * 0.85, 0, -size)
|
||||||
|
ctx.bezierCurveTo(size * 0.52, -size * 0.62, size * 0.4, -size * 0.24, 0, 0)
|
||||||
|
ctx.fillStyle = fill
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = ink
|
||||||
|
ctx.lineWidth = 1.7
|
||||||
|
ctx.stroke()
|
||||||
|
line(ctx, [[0, 0], [0, -size * 0.78]], ink, 1.3)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function vine(ctx: Ink, x: number, top: number, length: number, mirror: number) {
|
||||||
|
const ink = '#796840'
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(x, top)
|
||||||
|
ctx.scale(mirror, 1)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, 0)
|
||||||
|
for (let y = 0; y < length; y += 120) {
|
||||||
|
ctx.bezierCurveTo(-28, y + 34, 28, y + 86, 0, Math.min(y + 120, length))
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = ink
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.stroke()
|
||||||
|
for (let y = 38; y < length - 24; y += 60) {
|
||||||
|
const direction = Math.floor(y / 60) % 2 === 0 ? -1 : 1
|
||||||
|
leaf(ctx, 0, y, direction * 0.95, 38, '#b5af88', ink)
|
||||||
|
leaf(ctx, 0, y + 18, -direction * 0.85, 27, '#d5c7a0', ink)
|
||||||
|
if (y % 180 === 38) {
|
||||||
|
const bx = direction * 32
|
||||||
|
line(ctx, [[0, y], [bx, y - 19]], ink, 1.5)
|
||||||
|
for (const [dx, dy] of [[-6, -3], [6, -3], [0, -13]]) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(bx + dx!, y - 19 + dy!, 5, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = '#853e45'
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function manuscript(ctx: Ink, back: boolean) {
|
||||||
|
const burgundy = '#672b39'
|
||||||
|
const ink = '#493a30'
|
||||||
|
ctx.fillStyle = '#eee3c9'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
// A regular, fine paper tooth reads as print rather than baked illumination.
|
||||||
|
ctx.fillStyle = '#ded1b7'
|
||||||
|
for (let y = 111; y < 1315; y += 9) {
|
||||||
|
for (let x = (y % 2) * 5; x < width; x += 13) ctx.fillRect(x, y, 1, 1)
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = burgundy
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.strokeRect(78, 166, 868, 1072)
|
||||||
|
ctx.strokeStyle = '#a18b60'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.strokeRect(91, 180, 842, 1044)
|
||||||
|
vine(ctx, 139, 230, 900, 1)
|
||||||
|
vine(ctx, 885, 230, 900, -1)
|
||||||
|
for (const [x, y] of [[105, 194], [919, 194], [105, 1210], [919, 1210]]) {
|
||||||
|
diamond(ctx, x!, y!, 8, burgundy)
|
||||||
|
}
|
||||||
|
text(ctx, 'COLLECTOR SERIES', 512, 254, 21, burgundy, sans, 4, 680)
|
||||||
|
const emblemY = back ? 562 : 438
|
||||||
|
ctx.fillStyle = burgundy
|
||||||
|
ctx.fillRect(448, emblemY - 75, 128, 150)
|
||||||
|
ctx.strokeStyle = '#c9af72'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.strokeRect(457, emblemY - 66, 110, 132)
|
||||||
|
cross(ctx, 512, emblemY, 38, '#eee0b9', 6)
|
||||||
|
for (const x of [470, 554]) {
|
||||||
|
leaf(ctx, x, emblemY + 47, x < 512 ? -0.28 : 0.28, 34, '#bea16b', '#d8c397')
|
||||||
|
}
|
||||||
|
text(ctx, 'SANCTIFICATION', 512, back ? 746 : 651, back ? 49 : 56,
|
||||||
|
burgundy, serif, 0.3, 700)
|
||||||
|
line(ctx, [[222, back ? 805 : 712], [802, back ? 805 : 712]], '#a18b60', 1.5)
|
||||||
|
if (!back) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(512, 976)
|
||||||
|
for (const direction of [-1, 1]) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.scale(direction, 1)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, 28)
|
||||||
|
ctx.bezierCurveTo(70, 22, 142, -58, 162, -150)
|
||||||
|
ctx.strokeStyle = '#796840'
|
||||||
|
ctx.lineWidth = 2.5
|
||||||
|
ctx.stroke()
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const x = 36 + i * 26
|
||||||
|
const y = 16 - i * 27
|
||||||
|
leaf(ctx, x, y, -0.75, 39, '#b5af88', '#796840')
|
||||||
|
leaf(ctx, x, y, 0.8, 31, '#d5c7a0', '#796840')
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
diamond(ctx, 0, 28, 6, burgundy)
|
||||||
|
ctx.restore()
|
||||||
|
} else {
|
||||||
|
diamond(ctx, 512, 954, 8, burgundy)
|
||||||
|
}
|
||||||
|
footer(ctx, back, ink, 1174)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modern(ctx: Ink, back: boolean) {
|
||||||
|
const ivory = '#f0e9d5'
|
||||||
|
const muted = '#a7b6a0'
|
||||||
|
ctx.fillStyle = '#123b30'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
// The modern edition uses an editorial grid, not an ornamental frame.
|
||||||
|
line(ctx, [[104, 184], [920, 184]], '#66816a', 1.5)
|
||||||
|
text(ctx, 'COLLECTOR SERIES', 512, 232, 21, ivory, sans, 5.5, 800)
|
||||||
|
const cy = back ? 580 : 465
|
||||||
|
circle(ctx, 512, cy, 39, gold, 2)
|
||||||
|
cross(ctx, 512, cy, 23, ivory, 3)
|
||||||
|
line(ctx, [[512, cy - 56], [512, cy - 49]], gold)
|
||||||
|
line(ctx, [[512, cy + 49], [512, cy + 56]], gold)
|
||||||
|
text(ctx, 'SANCTIFICATION', 512, back ? 757 : 684, back ? 51 : 65,
|
||||||
|
ivory, sans, back ? 1.2 : 0.8, 834, '600')
|
||||||
|
line(ctx, [[480, back ? 823 : 769], [544, back ? 823 : 769]], gold, 3)
|
||||||
|
if (!back) {
|
||||||
|
for (const x of [490, 512, 534]) {
|
||||||
|
ctx.fillStyle = muted
|
||||||
|
ctx.fillRect(x - 2, 1040, 4, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line(ctx, [[104, 1131], [920, 1131]], '#66816a', 1.5)
|
||||||
|
footer(ctx, back, ivory, 1193)
|
||||||
|
}
|
||||||
|
|
||||||
|
function seams(ctx: Ink) {
|
||||||
|
ctx.fillStyle = gold
|
||||||
|
ctx.fillRect(0, 0, width, 100)
|
||||||
|
ctx.fillRect(0, 1315, width, 85)
|
||||||
|
ctx.strokeStyle = '#a38450'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let x = 8; x < width; x += 12) {
|
||||||
|
line(ctx, [[x, 7], [x, 23]], '#a38450', 1)
|
||||||
|
line(ctx, [[x, 78], [x, 94]], '#a38450', 1)
|
||||||
|
line(ctx, [[x, 1329], [x, 1386]], '#a38450', 1)
|
||||||
|
}
|
||||||
|
line(ctx, [[0, 99], [1024, 99]], '#745c35', 2)
|
||||||
|
line(ctx, [[0, 1316], [1024, 1316]], '#745c35', 2)
|
||||||
|
text(ctx, 'PULL TO OPEN', 500, 51, 24, '#252b23', sans, 3.5, 620, 'bold')
|
||||||
|
line(ctx, [[706, 51], [778, 51], [765, 39]], '#252b23', 3)
|
||||||
|
line(ctx, [[778, 51], [765, 63]], '#252b23', 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPackTexture(style: PackStyle, back = false): THREE.CanvasTexture {
|
||||||
|
if (!packStyles.includes(style)) throw new Error(`Unknown pack style: ${String(style)}`)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: false })
|
||||||
|
if (!ctx) throw new Error('Cannot create pack texture: Canvas 2D context is unavailable')
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
switch (style) {
|
||||||
|
case 'Cathedral glass': cathedral(ctx, back); break
|
||||||
|
case 'Illuminated manuscript': manuscript(ctx, back); break
|
||||||
|
case 'Quiet modern': modern(ctx, back); break
|
||||||
|
}
|
||||||
|
seams(ctx)
|
||||||
|
const texture = new THREE.CanvasTexture(canvas)
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace
|
||||||
|
texture.name = `${style} — ${back ? 'back' : 'front'}`
|
||||||
|
return texture
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
|
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
|
||||||
import { createFoilWrapper } from './foilWrapper'
|
import { createFoilWrapper } from './foilWrapper'
|
||||||
|
import type { PackStyle } from './packDesigns'
|
||||||
import {
|
import {
|
||||||
applyEdgeMaterialControls,
|
applyEdgeMaterialControls,
|
||||||
applyMaterialControls,
|
applyMaterialControls,
|
||||||
@@ -9,19 +10,38 @@ import {
|
|||||||
type SubstrateName,
|
type SubstrateName,
|
||||||
} from './cardMaterial'
|
} from './cardMaterial'
|
||||||
|
|
||||||
export type PackFixture = 'David' | 'Timothy'
|
|
||||||
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
|
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
|
||||||
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
|
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
|
||||||
|
|
||||||
export const packContents: readonly {
|
export interface PackCardSpec {
|
||||||
fixture: PackFixture
|
fixture: string
|
||||||
finish: FinishName
|
finish: FinishName
|
||||||
substrate: SubstrateName
|
substrate: SubstrateName
|
||||||
}[] = [
|
}
|
||||||
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
|
|
||||||
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
|
export const packSize = 10
|
||||||
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
|
export const packFinishes: readonly FinishName[] = ['Printed ink', 'Foil', 'Holographic']
|
||||||
]
|
export const packSubstrates: readonly SubstrateName[] = ['Paper', 'Linen', 'Metal', 'Wood']
|
||||||
|
|
||||||
|
function randomChoice<T>(values: readonly T[], random: () => number) {
|
||||||
|
const roll = random()
|
||||||
|
if (!Number.isFinite(roll) || roll < 0 || roll >= 1) {
|
||||||
|
throw new Error(`Random source must return a number in [0, 1); received ${roll}`)
|
||||||
|
}
|
||||||
|
return values[Math.floor(roll * values.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollPackContents(
|
||||||
|
fixtures: readonly string[],
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): PackCardSpec[] {
|
||||||
|
if (!fixtures.length) throw new Error('Cannot roll a pack without valid card artwork')
|
||||||
|
return Array.from({ length: packSize }, () => ({
|
||||||
|
fixture: randomChoice(fixtures, random),
|
||||||
|
finish: randomChoice(packFinishes, random),
|
||||||
|
substrate: randomChoice(packSubstrates, random),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
|
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
|
||||||
const stackDepthSpacing = 0.055
|
const stackDepthSpacing = 0.055
|
||||||
@@ -34,7 +54,8 @@ const inspectionCameraRetreat = inspectionDepth - 0.85
|
|||||||
|
|
||||||
interface PackOptions {
|
interface PackOptions {
|
||||||
canvas: HTMLCanvasElement
|
canvas: HTMLCanvasElement
|
||||||
textures: Record<PackFixture, { artwork: THREE.Texture; mask: THREE.Texture }>
|
contents: readonly PackCardSpec[]
|
||||||
|
textures: ReadonlyMap<string, { artwork: THREE.Texture; mask: THREE.Texture }>
|
||||||
backMaterial: THREE.Material
|
backMaterial: THREE.Material
|
||||||
normalMap: THREE.Texture
|
normalMap: THREE.Texture
|
||||||
environment: THREE.CubeTexture
|
environment: THREE.CubeTexture
|
||||||
@@ -94,6 +115,7 @@ export class PackOpening {
|
|||||||
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
||||||
state: PackState = 'sealed'
|
state: PackState = 'sealed'
|
||||||
paused = false
|
paused = false
|
||||||
|
readonly contents: readonly PackCardSpec[]
|
||||||
private readonly options: PackOptions
|
private readonly options: PackOptions
|
||||||
private readonly wrapper = createFoilWrapper()
|
private readonly wrapper = createFoilWrapper()
|
||||||
private readonly cards
|
private readonly cards
|
||||||
@@ -119,13 +141,16 @@ export class PackOpening {
|
|||||||
private pinchDistance: number | undefined
|
private pinchDistance: number | undefined
|
||||||
|
|
||||||
constructor(options: PackOptions) {
|
constructor(options: PackOptions) {
|
||||||
|
if (!options.contents.length) throw new Error('Pack must contain at least one card')
|
||||||
this.options = options
|
this.options = options
|
||||||
|
this.contents = options.contents.map(spec => ({ ...spec }))
|
||||||
this.root.name = 'PACK_PROTOTYPE'
|
this.root.name = 'PACK_PROTOTYPE'
|
||||||
this.root.position.y = 0.22
|
this.root.position.y = 0.22
|
||||||
this.root.visible = false
|
this.root.visible = false
|
||||||
this.root.add(this.wrapper.root)
|
this.root.add(this.wrapper.root)
|
||||||
this.cards = packContents.map((spec) => {
|
this.cards = this.contents.map((spec) => {
|
||||||
const textures = options.textures[spec.fixture]
|
const textures = options.textures.get(spec.fixture)
|
||||||
|
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`)
|
||||||
const material = createCardMaterial(
|
const material = createCardMaterial(
|
||||||
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
|
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
|
||||||
)
|
)
|
||||||
@@ -144,16 +169,16 @@ export class PackOpening {
|
|||||||
|
|
||||||
get view() {
|
get view() {
|
||||||
const number = `${this.index + 1} / ${this.cards.length}`
|
const number = `${this.index + 1} / ${this.cards.length}`
|
||||||
const spec = packContents[this.index]
|
const spec = this.contents[this.index]
|
||||||
const views: Record<PackState, { action: string; status: string; hint: string }> = {
|
const views: Record<PackState, { action: string; status: string; hint: string }> = {
|
||||||
sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' },
|
sealed: { action: 'Tear open', status: `Sealed · ${this.cards.length} cards`, hint: 'Drag the gold top seam to the right · or use Tear open' },
|
||||||
opening: {
|
opening: {
|
||||||
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
|
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
|
||||||
status: this.openingProgress < 0.38
|
status: this.openingProgress < 0.38
|
||||||
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
|
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
|
||||||
: this.openingProgress < 0.52 ? 'Strip curling away'
|
: this.openingProgress < 0.52 ? 'Strip curling away'
|
||||||
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
|
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
|
||||||
: this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack',
|
: this.openingProgress < 0.87 ? `Sliding out ${this.cards.length} cards` : 'Settling the face-down stack',
|
||||||
hint: this.openingProgress < 0.38
|
hint: this.openingProgress < 0.38
|
||||||
? 'Pull right along the seam · release to hold · Finish opening resumes'
|
? 'Pull right along the seam · release to hold · Finish opening resumes'
|
||||||
: 'Touch or wheel pauses motion · Skip finishes this step',
|
: 'Touch or wheel pauses motion · Skip finishes this step',
|
||||||
@@ -168,7 +193,7 @@ export class PackOpening {
|
|||||||
hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue',
|
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' },
|
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' },
|
complete: { action: 'Pack complete', status: `Complete · all ${this.cards.length} cards inspected`, hint: 'Choose New pack for another independent roll' },
|
||||||
}
|
}
|
||||||
return this.paused && this.motion
|
return this.paused && this.motion
|
||||||
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
|
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
|
||||||
@@ -186,9 +211,16 @@ export class PackOpening {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get style() { return this.wrapper.style }
|
||||||
|
|
||||||
|
setStyle(style: PackStyle) {
|
||||||
|
this.wrapper.setStyle(style)
|
||||||
|
this.options.onChange()
|
||||||
|
}
|
||||||
|
|
||||||
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
|
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
|
||||||
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
|
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
|
||||||
const textures = new Set<THREE.Texture>()
|
const textures = new Set<THREE.Texture>(this.wrapper.textures)
|
||||||
if (scene.environment) textures.add(scene.environment)
|
if (scene.environment) textures.add(scene.environment)
|
||||||
this.root.traverse((object) => {
|
this.root.traverse((object) => {
|
||||||
if (!(object instanceof THREE.Mesh)) return
|
if (!(object instanceof THREE.Mesh)) return
|
||||||
@@ -209,7 +241,7 @@ export class PackOpening {
|
|||||||
await renderer.compileAsync(this.root, this.camera, scene)
|
await renderer.compileAsync(this.root, this.camera, scene)
|
||||||
try {
|
try {
|
||||||
this.cards.forEach((card, index) => {
|
this.cards.forEach((card, index) => {
|
||||||
applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 })
|
applyEdgeMaterialControls(card.edge, { ...this.contents[index], condition: 1 })
|
||||||
})
|
})
|
||||||
await renderer.compileAsync(this.root, this.camera, scene)
|
await renderer.compileAsync(this.root, this.camera, scene)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -311,8 +343,9 @@ export class PackOpening {
|
|||||||
this.updateCamera()
|
this.updateCamera()
|
||||||
this.cards.forEach((card, index) => {
|
this.cards.forEach((card, index) => {
|
||||||
const target = this.stackPose(index)
|
const target = this.stackPose(index)
|
||||||
|
const stagger = this.cards.length === 1 ? 0 : index / (this.cards.length - 1) * 0.06
|
||||||
// All cards clear the mouth before the pouch moves aside. Depth spacing never changes.
|
// 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)
|
target.position.y += 4.4 * smooth(value, 0.62 + stagger, 0.8 + stagger) * (1 - settling)
|
||||||
setPose(card.object, target)
|
setPose(card.object, target)
|
||||||
})
|
})
|
||||||
const aside = smooth(value, 0.83, 0.87)
|
const aside = smooth(value, 0.83, 0.87)
|
||||||
@@ -340,9 +373,9 @@ export class PackOpening {
|
|||||||
})
|
})
|
||||||
if (state === 'inspecting' && !this.revealed.has(this.index)) {
|
if (state === 'inspecting' && !this.revealed.has(this.index)) {
|
||||||
this.revealed.add(this.index)
|
this.revealed.add(this.index)
|
||||||
applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 })
|
applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 })
|
||||||
this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
|
this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
|
||||||
detail: { index: this.index + 1, ...packContents[this.index] },
|
detail: { index: this.index + 1, ...this.contents[this.index] },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (state === 'complete' && !this.completed) {
|
if (state === 'complete' && !this.completed) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
select {
|
select,
|
||||||
|
input {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -108,6 +109,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select,
|
select,
|
||||||
|
input,
|
||||||
button {
|
button {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border: 1px solid rgba(227, 215, 181, 0.2);
|
border: 1px solid rgba(227, 215, 181, 0.2);
|
||||||
@@ -116,7 +118,8 @@ button {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select,
|
||||||
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 28px 0 10px;
|
padding: 0 28px 0 10px;
|
||||||
@@ -128,17 +131,39 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
select:hover {
|
select:hover,
|
||||||
|
input:hover {
|
||||||
border-color: rgba(218, 186, 102, 0.62);
|
border-color: rgba(218, 186, 102, 0.62);
|
||||||
background: #202630;
|
background: #202630;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
outline: 2px solid #d6b860;
|
outline: 2px solid #d6b860;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
color: #777f8a;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-state.has-errors {
|
||||||
|
color: #e59a8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar label:has(.catalog-state) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.viewport-shell {
|
.viewport-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -306,12 +331,18 @@ button:disabled {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-mode .toolbar > :not(:first-child) {
|
.toolbar .pack-style,
|
||||||
|
.pack-mode .toolbar > :not(:first-child):not(.pack-style) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pack-mode .toolbar .pack-style {
|
||||||
|
display: grid;
|
||||||
|
flex-basis: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.pack-mode .toolbar {
|
.pack-mode .toolbar {
|
||||||
flex: 0 1 180px;
|
flex: 0 1 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-mode .hint {
|
.pack-mode .hint {
|
||||||
|
|||||||
41
spikes/card-harness/vite.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { cardArtRoot, scanCardCatalog, writeCardCatalog } from './scripts/card-catalog.mjs'
|
||||||
|
|
||||||
|
function cardCatalogPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'card-catalog',
|
||||||
|
async buildStart() {
|
||||||
|
const catalog = await writeCardCatalog()
|
||||||
|
if (!catalog.cards.length || catalog.errors.length) {
|
||||||
|
this.error(`Invalid card catalog:\n${catalog.errors.join('\n') || 'No valid card pairs found'}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/__card_catalog', async (_request, response) => {
|
||||||
|
try {
|
||||||
|
const catalog = await scanCardCatalog()
|
||||||
|
response.statusCode = 200
|
||||||
|
response.setHeader('Content-Type', 'application/json')
|
||||||
|
response.setHeader('Cache-Control', 'no-store')
|
||||||
|
response.end(JSON.stringify(catalog))
|
||||||
|
} catch (error) {
|
||||||
|
response.statusCode = 500
|
||||||
|
response.setHeader('Content-Type', 'application/json')
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.watcher.add(cardArtRoot)
|
||||||
|
server.watcher.on('all', (_event, path) => {
|
||||||
|
if (path.startsWith(cardArtRoot) && path.toLowerCase().endsWith('.png')) {
|
||||||
|
server.ws.send({ type: 'custom', event: 'card-catalog:update' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [cardCatalogPlugin()],
|
||||||
|
})
|
||||||
138
spikes/timothy-layer-proof/build.mjs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const layout = JSON.parse(readFileSync(resolve(root, 'layout.json')));
|
||||||
|
const card = JSON.parse(readFileSync(resolve(root, 'card.json')));
|
||||||
|
const { width: W, height: H, radius, frameInset: inset, fields } = layout;
|
||||||
|
const out = resolve(root, 'output');
|
||||||
|
for (const dir of ['layers', 'cards', 'masks', 'review']) mkdirSync(resolve(out, dir), { recursive: true });
|
||||||
|
const run = (bin, args, input) => execFileSync(bin, args, { input, maxBuffer: 128 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
const magick = process.env.MAGICK_BIN || 'magick';
|
||||||
|
const inkscape = process.env.INKSCAPE_BIN || 'inkscape';
|
||||||
|
const path = name => resolve(out, name);
|
||||||
|
const hash = data => createHash('sha256').update(data).digest('hex');
|
||||||
|
const escape = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
||||||
|
const rect = ([x, y, w, h], attrs = String()) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${attrs}/>`;
|
||||||
|
const svg = (body, w = W, h = H) => `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
||||||
|
const round = (x, y, w, h, r) => `M${x+r},${y}H${x+w-r}A${r},${r} 0 0 1 ${x+w},${y+r}V${y+h-r}A${r},${r} 0 0 1 ${x+w-r},${y+h}H${x+r}A${r},${r} 0 0 1 ${x},${y+h-r}V${y+r}A${r},${r} 0 0 1 ${x+r},${y}Z`;
|
||||||
|
const runtimeFrame = `<path fill="${layout.inkColor}" fill-rule="evenodd" d="M0,0H${W}V${H}H0Z ${round(inset,inset,W-2*inset,H-2*inset,radius-inset)}"/>`;
|
||||||
|
const frame = `<path fill="${layout.inkColor}" fill-rule="evenodd" d="${round(0,0,W,H,radius)} ${round(inset,inset,W-2*inset,H-2*inset,radius-inset)}"/>`;
|
||||||
|
const backing = layout.panels.map(box => rect(box, `fill="${layout.panelColor}"`)).join('') +
|
||||||
|
`<path d="M48 357H1952 M48 2183H1952" stroke="${layout.inkColor}" stroke-width="6"/>`;
|
||||||
|
const textValues = { title: card.name.toUpperCase(), subtitle: card.subtitle, category: card.category.toUpperCase(), reference: `${card.reference} · ${card.translation}` };
|
||||||
|
assert.equal(card.verseLines.join(' '), card.verse, 'Verse line breaks must preserve exact source text');
|
||||||
|
const textElement = (id, f, value) => `<text id="${id}" x="${f.x}" y="${f.y}" font-family="${f.font}" font-size="${f.size}" text-anchor="${f.anchor}" fill="${layout.inkColor}">${escape(value)}</text>`;
|
||||||
|
const textMarkup = Object.entries(fields).map(([id, f]) => id === 'verse'
|
||||||
|
? card.verseLines.map((line, i) => textElement(`verse-${i}`, { ...f, y: f.y + i*f.lineHeight }, `${i === 0 ? '“' : ''}${line}${i === card.verseLines.length-1 ? '”' : ''}`)).join('')
|
||||||
|
: textElement(id, f, textValues[id])).join('');
|
||||||
|
const exportSvg = (name, body, w = W, h = H) => {
|
||||||
|
writeFileSync(path(`${name}.svg`), svg(body, w, h));
|
||||||
|
run(inkscape, [path(`${name}.svg`), '--export-area-page', '--export-png-color-mode=RGBA_8', `--export-filename=${path(`${name}.png`)}`]);
|
||||||
|
};
|
||||||
|
const rgba = file => run(magick, [file, '-alpha', 'on', '-depth', '8', 'rgba:-']);
|
||||||
|
const rawPng = (bytes, file, channels = 'rgba') => run(magick, ['-size', `${W}x${H}`, '-depth', '8', `${channels}:-`, file], bytes);
|
||||||
|
const source = resolve(root, 'source/timothy-art.png');
|
||||||
|
const dimensions = run(magick, ['identify', '-format', '%w %h', source]).toString().split(' ').map(Number);
|
||||||
|
assert.equal(dimensions[0]/dimensions[1], W/H, 'Source requires an explicitly reviewed crop');
|
||||||
|
run(magick, [source, '-resize', `${W}x${H}`, '-colorspace', 'sRGB', path('layers/art.png')]);
|
||||||
|
const fonts = {};
|
||||||
|
for (const family of new Set(Object.values(fields).map(f => f.font))) {
|
||||||
|
const [matched, file] = run('fc-match', ['-f', '%{family}\n%{file}', family]).toString().split('\n');
|
||||||
|
assert.equal(matched, family, `Missing font: ${family}`);
|
||||||
|
fonts[family] = { file, sha256: hash(readFileSync(file)) };
|
||||||
|
}
|
||||||
|
exportSvg('layers/frame', frame);
|
||||||
|
exportSvg('layers/frame-runtime', runtimeFrame);
|
||||||
|
exportSvg('layers/backing', backing);
|
||||||
|
exportSvg('layers/text', textMarkup);
|
||||||
|
exportSvg('layers/normal-overlay', backing + frame);
|
||||||
|
exportSvg('layers/normal-runtime-overlay', backing + runtimeFrame);
|
||||||
|
exportSvg('layers/card-silhouette', `<rect width="${W}" height="${H}" rx="${radius}" fill="white"/>`);
|
||||||
|
const queries = run(inkscape, [path('layers/text.svg'), '--query-all']).toString();
|
||||||
|
writeFileSync(path('review/text-bounds.csv'), queries);
|
||||||
|
const glyphBounds = {};
|
||||||
|
for (const row of queries.trim().split('\n')) { const [id, ...numbers] = row.split(','); glyphBounds[id] = numbers.map(Number); }
|
||||||
|
function fits(box, bounds) {
|
||||||
|
return bounds?.length === 4 && bounds[0] >= box[0] && bounds[1] >= box[1] && bounds[0]+bounds[2] <= box[0]+box[2] && bounds[1]+bounds[3] <= box[1]+box[3];
|
||||||
|
}
|
||||||
|
for (const [id, f] of Object.entries(fields)) {
|
||||||
|
const ids = id === 'verse' ? card.verseLines.map((_, i) => `verse-${i}`) : [id];
|
||||||
|
for (const textId of ids) assert.ok(fits(f.box, glyphBounds[textId]), `${textId} overflows: ${glyphBounds[textId]}`);
|
||||||
|
}
|
||||||
|
// Measure an actual overlong text object to prove the check rejects overflow.
|
||||||
|
writeFileSync(path('review/overflow-fixture.svg'), svg(textElement('too-long', fields.title, 'TIMOTHY '.repeat(12))));
|
||||||
|
const overflow = run(inkscape, [path('review/overflow-fixture.svg'), '--query-id=too-long', '--query-all']).toString().trim().split('\n').find(row => row.startsWith('too-long,'));
|
||||||
|
assert.ok(!fits(fields.title.box, overflow.split(',').slice(1).map(Number)), 'Overflow fixture was not rejected');
|
||||||
|
const art = rgba(path('layers/art.png'));
|
||||||
|
const normalOverlay = rgba(path('layers/normal-runtime-overlay.png'));
|
||||||
|
const backingPixels = rgba(path('layers/backing.png'));
|
||||||
|
const framePixels = rgba(path('layers/frame.png'));
|
||||||
|
const runtimeFramePixels = rgba(path('layers/frame-runtime.png'));
|
||||||
|
const alphaAt = (buf, x, y) => buf[(y*W+x)*4+3];
|
||||||
|
let inspectedJoinPixels = 0;
|
||||||
|
// Interior joins, deliberately away from the rounded exterior and antialias bands.
|
||||||
|
for (const [x0,y0,w,h] of [[50,110,20,230],[1930,110,20,230],[50,2200,20,470],[1930,2200,20,470],[110,50,1780,20],[110,2730,1780,20]]) {
|
||||||
|
for (let y=y0;y<y0+h;y++) for (let x=x0;x<x0+w;x++) {
|
||||||
|
assert.equal(alphaAt(normalOverlay,x,y),255, `Gap at ${x},${y}`); inspectedJoinPixels++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(alphaAt(normalOverlay,1000,1200),0, 'Art window must be transparent');
|
||||||
|
assert.equal(alphaAt(framePixels,1000,2400),0, 'Textless must expose the lower art');
|
||||||
|
const seamFixture = Buffer.from(normalOverlay);
|
||||||
|
seamFixture[(150*W+59)*4+3] = 0;
|
||||||
|
assert.notEqual(alphaAt(seamFixture,59,150),255, 'One-pixel seam fixture was not detected');
|
||||||
|
const smooth = (a,b,v) => { const t=Math.max(0,Math.min(1,(v-a)/(b-a))); return t*t*(3-2*t); };
|
||||||
|
const variants = { normal: ['normal-runtime-overlay','text'], textless: ['frame-runtime'], borderless: ['backing','text'], boundless: [] };
|
||||||
|
const decodedHashes = {};
|
||||||
|
for (const [name, layers] of Object.entries(variants)) {
|
||||||
|
const args = [path('layers/art.png')];
|
||||||
|
for (const layer of layers) args.push(path(`layers/${layer}.png`), '-compose', 'Over', '-composite');
|
||||||
|
// Runtime textures stay opaque through their square corners; harness geometry owns the rounded silhouette.
|
||||||
|
args.push('-alpha', 'off', path(`cards/timothy-${name}.png`));
|
||||||
|
run(magick,args);
|
||||||
|
const pixels = rgba(path(`cards/timothy-${name}.png`));
|
||||||
|
for (let i = 3; i < pixels.length; i += 4) assert.equal(pixels[i], 255, `${name} runtime texture has a transparent pixel at index ${Math.floor(i / 4)}`);
|
||||||
|
decodedHashes[name] = hash(pixels);
|
||||||
|
const mask = Buffer.alloc(W*H);
|
||||||
|
for(let i=0;i<W*H;i++) {
|
||||||
|
const p=i*4, r=art[p]/255,g=art[p+1]/255,b=art[p+2]/255;
|
||||||
|
const hi=Math.max(r,g,b),lo=Math.min(r,g,b),sat=hi===0?0:(hi-lo)/hi;
|
||||||
|
let coverage=smooth(.08,.58,sat)*smooth(.015,.16,hi);
|
||||||
|
// This proof treats the frame and text panels as printed ink; finish is on visible art.
|
||||||
|
if (name==='normal'||name==='textless') coverage*=1-runtimeFramePixels[p+3]/255;
|
||||||
|
if (name==='normal'||name==='borderless') coverage*=1-backingPixels[p+3]/255;
|
||||||
|
mask[i]=Math.round(255*coverage);
|
||||||
|
}
|
||||||
|
rawPng(mask,path(`masks/timothy-${name}-finish.png`),'gray');
|
||||||
|
for(const width of [1000,500]) run(magick,[path(`cards/timothy-${name}.png`),'-resize',String(width),path(`cards/timothy-${name}-${width}.png`)]);
|
||||||
|
for(const [id,f] of Object.entries(fields)) if(layers.includes('text')) {
|
||||||
|
const [x,y,w,h]=f.box;
|
||||||
|
for(let yy=y;yy<y+h;yy++) for(let xx=x;xx<x+w;xx++) assert.equal(mask[yy*W+xx],0,`Finish under ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The textless variants retain the original art's finish coverage in former panel regions.
|
||||||
|
const boundlessMask=run(magick,[path('masks/timothy-boundless-finish.png'),'-depth','8','gray:-']);
|
||||||
|
assert.ok(boundlessMask.subarray(2250*W,2530*W).some(v=>v>0),'Stale lower text protection in Boundless');
|
||||||
|
// Repeat an independent composition and compare decoded pixels, ignoring PNG metadata.
|
||||||
|
const repeated=run(magick,[path('layers/art.png'),path('layers/normal-runtime-overlay.png'),'-compose','Over','-composite',path('layers/text.png'),'-compose','Over','-composite','-alpha','off','-depth','8','rgba:-']);
|
||||||
|
assert.equal(hash(repeated),decodedHashes.normal,'Composition is not repeatable');
|
||||||
|
const embeddedArt=`<image width="${W}" height="${H}" xlink:href="data:image/png;base64,${readFileSync(source).toString('base64')}"/>`;
|
||||||
|
// Inkscape's actual namespace is required for editable layer groups.
|
||||||
|
const group=(label,body)=>`<g inkscape:groupmode="layer" inkscape:label="${label}" id="${label}">${body}</g>`;
|
||||||
|
writeFileSync(path('timothy-editable.svg'),svg(group('Art',embeddedArt)+group('Backing',backing)+group('Frame',frame)+group('Text',textMarkup)).replace('http://www.inkscape.org/namespaces/inkscape','http://www.inkscape.org/namespaces/inkscape'));
|
||||||
|
const guides=Object.entries(fields).map(([id,f])=>rect(f.box,'fill="none" stroke="#db4652" stroke-width="4"')+`<text x="${f.box[0]}" y="${f.box[1]-12}" font-family="Noto Sans" font-size="30" fill="#db4652">${id}</text>`).join('')+rect(layout.focalRegion,'fill="none" stroke="#4289bd" stroke-width="5" stroke-dasharray="20 12"');
|
||||||
|
exportSvg('review/layout-guide',embeddedArt+backing+frame+textMarkup+guides);
|
||||||
|
let sheet=`<rect width="2240" height="920" fill="#f6f3eb"/><text x="60" y="62" font-family="Noto Serif" font-size="32" fill="#304747">Timothy / Common — four printings, one illustration</text>`;
|
||||||
|
for(const [i,name] of Object.keys(variants).entries()) {
|
||||||
|
const x=60+i*550;
|
||||||
|
sheet+=`<text x="${x}" y="120" font-family="Noto Sans" font-size="22" fill="#304747">${name[0].toUpperCase()+name.slice(1)}</text><image x="${x}" y="150" width="500" height="700" xlink:href="data:image/png;base64,${readFileSync(path(`cards/timothy-${name}-500.png`)).toString('base64')}"/>`;
|
||||||
|
}
|
||||||
|
exportSvg('review/printings',sheet,2240,920);
|
||||||
|
const report={status:'passed',template:layout.revision,source:{dimensions,sha256:hash(readFileSync(source)),transform:`Uniform scale ${W/dimensions[0]}; no crop. Master is upscaled from generated source.`},tools:{inkscape:run(inkscape,['--version']).toString().trim(),imagemagick:run(magick,['-version']).toString().split('\n')[0]},fonts,glyphBounds,inspectedJoinPixels,runtimeTextureAlpha:'Every card pixel is fully opaque; harness geometry owns the rounded silhouette',negativeChecks:['Rendered overlong title rejected','One-pixel alpha seam detected'],decodedHashes,finishRecipe:'Timothy saturation smoothstep(0.08,0.58) × value smoothstep(0.015,0.16), occluded by active printed-ink frame/backing',limitations:['Visual approval pending','No automated semantic or optical-centering judgment','No runtime lighting review performed by this script','Two-line alternate title layout not implemented in this proof']};
|
||||||
|
writeFileSync(path('validation.json'),JSON.stringify(report,null,2)+'\n');
|
||||||
|
console.log(`Built four printings and aligned finish masks. Validation passed (${inspectedJoinPixels} join pixels). See ${path('review/printings.png')}`);
|
||||||
13
spikes/timothy-layer-proof/card.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "timothy-common-proof-v1",
|
||||||
|
"name": "Timothy",
|
||||||
|
"rarity": "Common",
|
||||||
|
"subtitle": "Faithful disciple",
|
||||||
|
"category": "Person",
|
||||||
|
"verse": "I have fought a good fight, I have finished my course, I have kept the faith:",
|
||||||
|
"verseLines": ["I have fought a good fight,", "I have finished my course,", "I have kept the faith:"],
|
||||||
|
"reference": "2 Timothy 4:7",
|
||||||
|
"translation": "KJV",
|
||||||
|
"source": "https://www.biblegateway.com/passage/?search=2+Timothy+4%3A7&version=KJV",
|
||||||
|
"context": "Paul's words to Timothy; not a quotation spoken by Timothy. The scene is an illustrative interpretation, not a claim to depict a documented encounter."
|
||||||
|
}
|
||||||
18
spikes/timothy-layer-proof/layout.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"revision": "common-proof-v1",
|
||||||
|
"width": 2000,
|
||||||
|
"height": 2800,
|
||||||
|
"radius": 88.888889,
|
||||||
|
"frameInset": 60,
|
||||||
|
"panelColor": "#eee8d5",
|
||||||
|
"inkColor": "#304747",
|
||||||
|
"panels": [[48, 48, 1904, 312], [48, 2180, 1904, 572]],
|
||||||
|
"focalRegion": [160, 430, 1680, 1680],
|
||||||
|
"fields": {
|
||||||
|
"title": {"box": [160, 105, 1680, 105], "x": 1000, "y": 197, "size": 110, "font": "Noto Serif", "anchor": "middle"},
|
||||||
|
"subtitle": {"box": [160, 240, 1100, 64], "x": 160, "y": 288, "size": 44, "font": "Noto Sans", "anchor": "start"},
|
||||||
|
"category": {"box": [1400, 240, 440, 64], "x": 1840, "y": 288, "size": 38, "font": "Noto Sans", "anchor": "end"},
|
||||||
|
"verse": {"box": [160, 2250, 1680, 280], "x": 1000, "y": 2320, "size": 72, "lineHeight": 95, "font": "Noto Serif", "anchor": "middle"},
|
||||||
|
"reference": {"box": [160, 2580, 1680, 80], "x": 1000, "y": 2635, "size": 44, "font": "Noto Sans", "anchor": "middle"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 497 KiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-borderless.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 658 KiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-boundless.png
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-normal-1000.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-normal-500.png
Normal file
|
After Width: | Height: | Size: 441 KiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-normal.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-textless-500.png
Normal file
|
After Width: | Height: | Size: 603 KiB |
BIN
spikes/timothy-layer-proof/output/cards/timothy-textless.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
spikes/timothy-layer-proof/output/layers/art.png
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
BIN
spikes/timothy-layer-proof/output/layers/backing.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
1
spikes/timothy-layer-proof/output/layers/backing.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><rect x="48" y="48" width="1904" height="312" fill="#eee8d5"/><rect x="48" y="2180" width="1904" height="572" fill="#eee8d5"/><path d="M48 357H1952 M48 2183H1952" stroke="#304747" stroke-width="6"/></svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
BIN
spikes/timothy-layer-proof/output/layers/card-silhouette.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><rect width="2000" height="2800" rx="88.888889" fill="white"/></svg>
|
||||||
|
After Width: | Height: | Size: 263 B |
BIN
spikes/timothy-layer-proof/output/layers/frame-runtime.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><path fill="#304747" fill-rule="evenodd" d="M0,0H2000V2800H0Z M88.888889,60H1911.111111A28.888889000000006,28.888889000000006 0 0 1 1940,88.888889V2711.111111A28.888889000000006,28.888889000000006 0 0 1 1911.111111,2740H88.888889A28.888889000000006,28.888889000000006 0 0 1 60,2711.111111V88.888889A28.888889000000006,28.888889000000006 0 0 1 88.888889,60Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
BIN
spikes/timothy-layer-proof/output/layers/frame.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
spikes/timothy-layer-proof/output/layers/frame.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><path fill="#304747" fill-rule="evenodd" d="M88.888889,0H1911.111111A88.888889,88.888889 0 0 1 2000,88.888889V2711.111111A88.888889,88.888889 0 0 1 1911.111111,2800H88.888889A88.888889,88.888889 0 0 1 0,2711.111111V88.888889A88.888889,88.888889 0 0 1 88.888889,0Z M88.888889,60H1911.111111A28.888889000000006,28.888889000000006 0 0 1 1940,88.888889V2711.111111A28.888889000000006,28.888889000000006 0 0 1 1911.111111,2740H88.888889A28.888889000000006,28.888889000000006 0 0 1 60,2711.111111V88.888889A28.888889000000006,28.888889000000006 0 0 1 88.888889,60Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 762 B |
BIN
spikes/timothy-layer-proof/output/layers/normal-overlay.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><rect x="48" y="48" width="1904" height="312" fill="#eee8d5"/><rect x="48" y="2180" width="1904" height="572" fill="#eee8d5"/><path d="M48 357H1952 M48 2183H1952" stroke="#304747" stroke-width="6"/><path fill="#304747" fill-rule="evenodd" d="M88.888889,0H1911.111111A88.888889,88.888889 0 0 1 2000,88.888889V2711.111111A88.888889,88.888889 0 0 1 1911.111111,2800H88.888889A88.888889,88.888889 0 0 1 0,2711.111111V88.888889A88.888889,88.888889 0 0 1 88.888889,0Z M88.888889,60H1911.111111A28.888889000000006,28.888889000000006 0 0 1 1940,88.888889V2711.111111A28.888889000000006,28.888889000000006 0 0 1 1911.111111,2740H88.888889A28.888889000000006,28.888889000000006 0 0 1 60,2711.111111V88.888889A28.888889000000006,28.888889000000006 0 0 1 88.888889,60Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 960 B |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><rect x="48" y="48" width="1904" height="312" fill="#eee8d5"/><rect x="48" y="2180" width="1904" height="572" fill="#eee8d5"/><path d="M48 357H1952 M48 2183H1952" stroke="#304747" stroke-width="6"/><path fill="#304747" fill-rule="evenodd" d="M0,0H2000V2800H0Z M88.888889,60H1911.111111A28.888889000000006,28.888889000000006 0 0 1 1940,88.888889V2711.111111A28.888889000000006,28.888889000000006 0 0 1 1911.111111,2740H88.888889A28.888889000000006,28.888889000000006 0 0 1 60,2711.111111V88.888889A28.888889000000006,28.888889000000006 0 0 1 88.888889,60Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 758 B |
BIN
spikes/timothy-layer-proof/output/layers/text.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
1
spikes/timothy-layer-proof/output/layers/text.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><text id="title" x="1000" y="197" font-family="Noto Serif" font-size="110" text-anchor="middle" fill="#304747">TIMOTHY</text><text id="subtitle" x="160" y="288" font-family="Noto Sans" font-size="44" text-anchor="start" fill="#304747">Faithful disciple</text><text id="category" x="1840" y="288" font-family="Noto Sans" font-size="38" text-anchor="end" fill="#304747">PERSON</text><text id="verse-0" x="1000" y="2320" font-family="Noto Serif" font-size="72" text-anchor="middle" fill="#304747">“I have fought a good fight,</text><text id="verse-1" x="1000" y="2415" font-family="Noto Serif" font-size="72" text-anchor="middle" fill="#304747">I have finished my course,</text><text id="verse-2" x="1000" y="2510" font-family="Noto Serif" font-size="72" text-anchor="middle" fill="#304747">I have kept the faith:”</text><text id="reference" x="1000" y="2635" font-family="Noto Sans" font-size="44" text-anchor="middle" fill="#304747">2 Timothy 4:7 · KJV</text></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
BIN
spikes/timothy-layer-proof/output/review/layout-guide.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800"><text id="too-long" x="1000" y="197" font-family="Noto Serif" font-size="110" text-anchor="middle" fill="#304747">TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY TIMOTHY </text></svg>
|
||||||
|
After Width: | Height: | Size: 418 B |
BIN
spikes/timothy-layer-proof/output/review/printings.png
Normal file
|
After Width: | Height: | Size: 930 KiB |
1
spikes/timothy-layer-proof/output/review/printings.svg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
8
spikes/timothy-layer-proof/output/review/text-bounds.csv
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
svg9,164.268,117.25,1672.05,2528.31
|
||||||
|
title,745.295,117.25,511.94,80.85
|
||||||
|
subtitle,164.268,254.34,315.744,44.22
|
||||||
|
category,1696.51,260.45,139.802,27.93
|
||||||
|
verse-0,541.72,2264.2,916.56,73.08
|
||||||
|
verse-1,553.348,2359.2,892.008,73.08
|
||||||
|
verse-2,632.584,2454.56,733.536,72.72
|
||||||
|
reference,812.318,2601.56,377.476,44
|
||||||
|
1
spikes/timothy-layer-proof/output/timothy-editable.svg
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
95
spikes/timothy-layer-proof/output/validation.json
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"template": "common-proof-v1",
|
||||||
|
"source": {
|
||||||
|
"dimensions": [
|
||||||
|
1060,
|
||||||
|
1484
|
||||||
|
],
|
||||||
|
"sha256": "64431ddf894056d0936200117002ecce120fe553e2cce81bb4763e783e646393",
|
||||||
|
"transform": "Uniform scale 1.8867924528301887; no crop. Master is upscaled from generated source."
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"inkscape": "Inkscape 1.2.2 (b0a8486541, 2022-12-01)",
|
||||||
|
"imagemagick": "Version: ImageMagick 7.1.2-31 Q16-HDRI x86_64 8309dc92a:20260903 https://imagemagick.org"
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"Noto Serif": {
|
||||||
|
"file": "/usr/share/fonts/truetype/noto/NotoSerif-Regular.ttf",
|
||||||
|
"sha256": "9d7583b7dc9e812afd32a14280c5cac3160012efe50c8d08938f4fea266ff67f"
|
||||||
|
},
|
||||||
|
"Noto Sans": {
|
||||||
|
"file": "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||||
|
"sha256": "89c3c497f618fdaa0b2d1e98fef93582f28c71debd2c4a8cdf41f190ced2909d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"glyphBounds": {
|
||||||
|
"svg9": [
|
||||||
|
164.268,
|
||||||
|
117.25,
|
||||||
|
1672.05,
|
||||||
|
2528.31
|
||||||
|
],
|
||||||
|
"title": [
|
||||||
|
745.295,
|
||||||
|
117.25,
|
||||||
|
511.94,
|
||||||
|
80.85
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
164.268,
|
||||||
|
254.34,
|
||||||
|
315.744,
|
||||||
|
44.22
|
||||||
|
],
|
||||||
|
"category": [
|
||||||
|
1696.51,
|
||||||
|
260.45,
|
||||||
|
139.802,
|
||||||
|
27.93
|
||||||
|
],
|
||||||
|
"verse-0": [
|
||||||
|
541.72,
|
||||||
|
2264.2,
|
||||||
|
916.56,
|
||||||
|
73.08
|
||||||
|
],
|
||||||
|
"verse-1": [
|
||||||
|
553.348,
|
||||||
|
2359.2,
|
||||||
|
892.008,
|
||||||
|
73.08
|
||||||
|
],
|
||||||
|
"verse-2": [
|
||||||
|
632.584,
|
||||||
|
2454.56,
|
||||||
|
733.536,
|
||||||
|
72.72
|
||||||
|
],
|
||||||
|
"reference": [
|
||||||
|
812.318,
|
||||||
|
2601.56,
|
||||||
|
377.476,
|
||||||
|
44
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inspectedJoinPixels": 99200,
|
||||||
|
"runtimeTextureAlpha": "Every card pixel is fully opaque; harness geometry owns the rounded silhouette",
|
||||||
|
"negativeChecks": [
|
||||||
|
"Rendered overlong title rejected",
|
||||||
|
"One-pixel alpha seam detected"
|
||||||
|
],
|
||||||
|
"decodedHashes": {
|
||||||
|
"normal": "da8a7aa62f0bc4369527859f2f625adb656f201059a765efe6238198b36d6d64",
|
||||||
|
"textless": "3d64324a77eb31313c7123018580fd4ab834aeff6fe0b14b586814f1f8c9980c",
|
||||||
|
"borderless": "bffe0a4d7a58caeda72fdc990333e5466bd89a5ac7f19f86d241fbbb412cd6de",
|
||||||
|
"boundless": "d418f16ff392b1f2b3e237db0a67e1b218b7aeeb5f0d6927a4cde2b984ffb3a9"
|
||||||
|
},
|
||||||
|
"finishRecipe": "Timothy saturation smoothstep(0.08,0.58) × value smoothstep(0.015,0.16), occluded by active printed-ink frame/backing",
|
||||||
|
"limitations": [
|
||||||
|
"Visual approval pending",
|
||||||
|
"No automated semantic or optical-centering judgment",
|
||||||
|
"No runtime lighting review performed by this script",
|
||||||
|
"Two-line alternate title layout not implemented in this proof"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
spikes/timothy-layer-proof/source/timothy-art.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |