Case Study
VEND: 3D Portfolio Publishing System is a suite of WordPress plugins I designed and developed to help 3D artists publish interactive work as structured, editorial case studies. The project combines a custom 3D Projects content type, GLB model selection through the WordPress Media Library, configurable project metadata, and a responsive <model-viewer> experience with visual fallbacks.
I also explored a companion 3D Turntable Comparator Gutenberg block for presenting an asset’s progression such as structural topology versus a finished material or product-visualization model through an interactive before/after interface. Together, the work investigates how WordPress can support richer, creator-friendly 3D publishing while maintaining a clear content model, practical editor controls, responsive front-end behavior, and progressive enhancement.
The following is a list of 5 progressively involved plugins that showcase my ability to craft within the WordPress platform including the handling of front-end performance, media handling, block editor integration, under the discipline and art of software engineering.
1. 3D Project Viewer Block
2. 3D Portfolio Project Type
3. Before/After 3D Turntable Comparator
4. 3D Asset Library + Shortcode Injector
5. WooCommerce 3D Product Viewer
PLUGIN #1: 3D Project Viewer Block

CHALLENGE: WordPress handles images, video, and editorial content well, but interactive 3D is still awkward for many creators. Embedding a model often means custom code, a third-party iframe, or a one-off implementation that editors cannot confidently update themselves. The challenge was to create a simple WordPress-native block that lets an editor place a 3D model inside any page or post without needing to understand WebGL, file paths, or front-end code.
PROCESS: I designed a focused Gutenberg block around a GLB-first workflow. Editors select a model through the WordPress Media Library, then adjust a small set of meaningful controls: viewer height, background color, auto-rotation, and camera interaction.
On the front end, the block uses <model-viewer> to provide orbit controls, zoom, loading behavior, and responsive rendering. I treated the viewer as a progressive-enhancement component: the model is interactive for capable browsers, while a featured image/poster and descriptive text provide a useful fallback.
I also explored conditional asset loading so the 3D runtime is only requested on pages that actually include a viewer. That keeps the plugin from adding an unnecessary performance cost across an entire WordPress site.
OUTCOME: The result is a reusable publishing block that turns a normally technical task into a familiar editorial workflow:
Select a GLB, add context, publish an interactive project.
It demonstrates how a small, constrained block can make 3D content approachable for nontechnical editors while still giving designers and artists control over presentation.
DETAILS
Key Decisions:
- GLB over glTF-separate. A
.glbis a single binary container holding mesh, materials, and textures, so it survives a WordPress Media Library upload without broken relative asset paths. Multi-file.gltfexports would require the editor to upload and preserve sibling.binand texture files, an unacceptable authoring burden. <model-viewer>instead of a hand-rolled three.js scene. The web component ships orbit controls, lazy loading, poster states, ARIA handling, and a documented attribute API. Writing a custom renderer would have meant owning camera math, resize observers, and loading states for a portfolio demo, cost without differentiation.- Conditional runtime loading. The 3D library is only enqueued when the current singular post actually contains the block, checked with
has_block()beforewp_enqueue_script(). This keeps a multi-megabyte WebGL runtime off pages that don’t need it.
Trade-offs:
| Decision | Gain | Cost |
|---|---|---|
| Pinned CDN for the viewer runtime | Small plugin ZIP, fast install | External dependency; no offline install |
| Server-rendered dynamic block | Markup can be changed without re-saving posts | Editor cannot show the true final render |
| Attribute-stored URL and attachment ID | Front end works even if an ID goes stale | Two sources of truth to keep in sync |
Interesting Challenge:
WordPress’s MediaUpload filters by MIME type, and GLB files are frequently stored as application/octet-stream rather than model/gltf-binary depending on how the server sniffs the upload. Filtering on the “correct” MIME type alone silently hid every uploaded model. The fix was to accept both types and then validate the filename extension in the onSelect handler, a defensive double check rather than trusting server MIME detection.
PLUGIN #2: 3D Portfolio Project Type

CHALLENGE: A 3D portfolio is more than an image gallery. Each project needs to connect an interactive asset with the production context that makes it meaningful: software, tools, materials, model type, render engine, geometry information, collaborators, and process documentation.
Using regular WordPress posts for this information makes the structure inconsistent. Using fully custom pages makes updates difficult and prevents a portfolio from scaling.
PROCESS: I designed a custom 3D Projects post type as a dedicated publishing model for 3D artists and studios.
Each project includes:
- A title, excerpt, featured image, and normal Gutenberg case-study content
- A GLB model selected from the WordPress Media Library
- A featured-image fallback and model description
- Structured metadata for year, software, render engine, model type, and triangle count
- A 3D Discipline taxonomy for categories such as product
The project type combines WordPress’s established editorial patterns with custom fields and front-end rendering. I kept technical data separate from narrative content: structured metadata makes information consistent and queryable, while the block editor remains available for process images, writing, and visual storytelling.
OUTCOME: The result is a small WordPress publishing system for 3D work rather than a collection of one-off portfolio pages.
An artist can add a new 3D project through a repeatable workflow, while the public site maintains a consistent presentation across archive pages, individual case studies, and related work. It demonstrates content modeling, custom post types, taxonomies, metadata registration, Media Library integration, and theme-compatible front-end rendering.
DETAILS
Key Decisions:
- Custom post type, not a category of posts. A 3D project has a fundamentally different shape than a blog post: it owns a model reference, a fallback poster, and five pieces of production metadata. Modelling that as tagged posts would have leaked technical fields into unrelated content.
- Registered meta, not raw post meta. Declaring each field through
register_post_meta()with an explicit type,single,sanitize_callback, andshow_in_restmakes the data typed, sanitized at the boundary, and available to the REST API — which is what makes the content portable rather than trapped in a theme. - Structured metadata separated from narrative content. Year, software, render engine, model type, and triangle count are fields; process writing and imagery stay in Gutenberg. This keeps the technical data queryable and consistent while leaving storytelling unconstrained.
- Taxonomy-driven related projects. Related work is derived from shared 3D Discipline terms via a
tax_query, so relationships emerge from classification instead of requiring manual cross-linking on every project.
Trade-offs:
Custom meta boxes were chosen over a full block-based editing experience for the technical fields. Meta boxes are the older API and feel less native in the block editor, but they are stable, dependency-free, and unambiguous about where the data lives, a reasonable choice for structured, non-editorial fields.
Interesting Challenge:
Front-end rendering had to work in an unknown theme. Rather than shipping template overrides that fight the active theme, the plugin injects its output through content filters and inherits typography and layout from the theme. Less pixel control, far fewer compatibility failures.
PLUGIN #3: Before/After 3D Turntable Comparator
Drag the divider to compare stages. Drag either model to inspect both views.
CHALLENGE: Finished renders often hide the most interesting part of 3D work: the evolution from base geometry to a final polished asset. Static before-and-after images can communicate surface appearance, but they cannot show topology, form, material treatment, or construction from the same angle. The challenge was to create an interactive publishing component that allows a visitor to compare two related 3D stages directly.
PROCESS: I designed a Gutenberg-based 3D Turntable Comparator that accepts two GLB files:
- A base state, such as a wireframe, low-poly mesh, clay study, or early design revision
- A final state, such as a textured, lit, high-detail, or production-ready model
The public component layers two interactive model viewers on top of one another. A draggable divider reveals either side of the comparison, while synchronized camera behavior keeps both models aligned as a visitor orbits the view.
The block includes controls for:
- Left and right labels
- Initial divider position
- Background color
- Auto-rotation
- GLB selection through the Media Library
OUTCOME: For usability, the divider is backed by a native range input, making the comparison operable beyond a purely mouse-driven interaction. I also designed the workflow around matching model origin, scale, and orientation so an artist can create a convincing comparison without manually aligning content in the browser.
DETAILS
(Note: This effort is where the most instructive engineering happened, because the first three builds failed.)
The Failure Chain:
- v0.1: block absent from the inserter. The editor JavaScript was attached to a registered-but-never-enqueued script handle.
wp_add_inline_script()only prints when its handle is genuinely in the queue, so WordPress knew a server-side block existed while Gutenberg never executedregisterBlockType(). Fixed by enqueueing onenqueue_block_editor_assets. - v0.2: script runs,
getBlockType()returnsundefined. With the script now executing, the real bug surfaced: the block was registered twice under the same name, once in PHP and once in JS. Client registration lost the conflict and returnedundefined, the documented signal thatregisterBlockType()refused the definition. - v0.3: invalid render path. The
block.jsonrenderproperty pointed at the plugin bootstrap file instead of a dedicated render template.renderexpects a template, not an entry point. - v0.4: the correct shape.
block.jsonas the single source of truth for name, attributes, and assets; a dedicated PHPrender_callbackfor dynamic output; the third-party viewer registered as its own asset rather than imported remotely from inside a WordPress front-end module.
What this debugging taught:
The console noise was as instructive as the fix. The environment threw a steady stream of cross-origin failures for TasteWP’s injected onboarding assets, a blocked Cloudflare beacon, a missing favicon, and a 404 on /wp-json/wp/v2/media/335. None touched the block. Isolating signal meant asserting registration directly in the console, wp.blocks.getBlockType('bo/3d-turntable-comparator'), instead of reading error logs and guessing. That one assertion distinguished “not registered” from “registered but not visible in the inserter,” which are two entirely different bugs with two entirely different fixes.
The stale media/335 request was its own lesson: passing a saved attachment ID to MediaUpload makes the editor fetch a record that may not exist after a site migration, even though the front end renders fine from the stored URL.
Technical Design Decisions:
- Camera synchronization via
camera-change. Both viewers listen for the event and mirror orbit and target onto the sibling, with a re-entrancy guard so the mirrored update doesn’t echo back into an infinite loop. - Auto-rotation deliberately excluded from sync. Auto-rotation turns the model rather than moving the camera, so it never emits
camera-change. Rather than fake it, the documented behavior is that synchronization applies to manual orbiting only. - A native
<input type="range">behind the visual divider. The drag handle is a styled layer over a real form control, so the comparison is keyboard-operable and screen-reader-addressable without reimplementing focus and key handling. - Alignment pushed to authoring, not runtime. Rather than attempting automatic model alignment in the browser, the workflow requires both GLBs to share origin, scale, and orientation. Cheap for an artist exporting from one Blender scene; expensive and fragile to solve at runtime.
- Two simultaneous WebGL contexts are an accepted performance cost, mitigated by keeping the editor preview as a lightweight configuration panel so authors never load two scenes while writing.
PLUGIN #4: 3D Asset Library + Shortcode Injector
[ [asset] slug=”” ]
CHALLENGE: As a studio or artist produces more 3D content, models become difficult to locate, reuse, and document. Files may sit in folders with inconsistent names, while editors need a simple way to place approved assets into posts, pages, product stories, or project case studies. The challenge was to explore a reusable asset-management layer that treats 3D models as organized editorial resources rather than isolated file uploads.
PROCESS: I designed a concept for a 3D Asset Library within WordPress. Each asset would have its own managed record, including:
- GLB model file
- Preview or fallback image
- Asset title and description
- Categories and tags
- License or usage status
- Artist/source credit
- Software and production notes
- Technical metadata such as file size, polycount, or version
- Reusable shortcode and block insertion options
OUTCOME: Editors could browse a curated asset collection, search by tag or category, and insert an approved 3D model into content using either a Gutenberg selector or a shortcode such as: [ bo_3d_asset slug=”object-study-01″ ]
The implementation concept prioritizes attachment IDs and structured WordPress metadata rather than hard-coded URLs, allowing asset references to remain connected to the Media Library and easier to maintain over time.
DETAILS
Key Decisions:
- Attachment IDs as the canonical reference, never URLs. Hard-coded URLs break on domain change, migration, or HTTPS transitions. Resolving the file from an attachment ID at render time keeps every asset reference bound to the Media Library, the same principle that made the stale-ID
404in the comparator diagnosable rather than mysterious. - Dual insertion paths: block and shortcode. The block is the modern editorial surface; the shortcode is the compatibility layer for classic-editor content, page builders, widgets, and template files. Supporting both means one asset record can be reused across a site that hasn’t fully migrated to Gutenberg.
- Assets as a first-class post type, not taxonomy terms on media. Assets need their own body copy, license status, credit, and version history. That’s a content record, not a label.
Trade-offs:
The shortcode surface is a long-term maintenance liability, attribute parsing, escaping, and versioned behavior all have to be preserved indefinitely, because shortcodes live inside saved post content. Accepted because the alternative is asset references that break when an author uses anything other than the block editor.
Interesting Challenge:
Reuse creates a cache-invalidation problem the single-embed plugins don’t have: when one asset is referenced across twenty pages and its model is replaced, every one of those pages must reflect the change. That argues for rendering from the live asset record on each request and caching at the fragment level, rather than baking resolved markup into saved content.
PLUGIN #5: WooCommerce 3D Product Viewer
CHALLENGE: Online product pages rely heavily on photographs, but many physical objects are difficult to understand through fixed images alone. Customers may need to inspect shape, material, scale, angles, and construction before deciding to purchase. The challenge was to explore how interactive 3D could become a useful part of a WooCommerce product experience without turning product management into a technical workflow.
PROCESS: I designed a WooCommerce extension concept that adds a 3D Model field to the product editor. A store manager would select a GLB asset through the Media Library, add a fallback product image, and configure a small number of display settings.
On the public product page, the plugin would place an interactive viewer within the product-media area. The experience would support:
- Orbit and zoom controls
- Optional auto-rotation
- Responsive viewer sizing
- Loading and image-poster states
- Accessible descriptive text
- Conditional loading so the 3D runtime only appears for products that include a model
The implementation concept respects WooCommerce’s existing product data model rather than creating a competing catalog. The 3D asset becomes an enhancement to an existing product record, just as a gallery image, downloadable file, or product attribute would be.
Korean Free Water Vending Machine 3D model
This is a 3D model of a free water vending machine found in Seoul.
OUTCOME: The WooCommerce 3D Product Viewer concept makes interactive product visualization manageable for merchants who do not write code. It gives shoppers a richer way to inspect an object while keeping the administrative workflow familiar: select a model, set a fallback, publish the product.
It demonstrates how a focused WordPress plugin can extend an established commerce workflow through custom product metadata, front-end performance considerations, responsive UI, and progressive enhancement.
DETAILS
Key Decisions:
- Extend the existing product data model. The 3D asset is stored as product meta on the existing product record and surfaced through a tab in WooCommerce’s product data panel. Creating a parallel catalog to hold models would fracture inventory, variations, and reporting.
- Hook into the product gallery rather than replace it. The viewer is injected through WooCommerce’s existing product-image hooks so photography remains primary and the model is an enhancement which also keeps the plugin from breaking themes that customize the gallery.
- Poster image first, model second. The fallback product image renders immediately and the model loads behind it. On a commerce page, a blank canvas during a multi-megabyte model fetch is a conversion problem, not just a polish problem.
Trade-offs:
| Decision | Gain | Cost |
|---|---|---|
| Product-level meta | Simple, familiar admin workflow | No per-variation models |
| Conditional runtime enqueue | No 3D cost on catalog or checkout pages | Requires a meta check on every product page |
| CDN-hosted viewer runtime | Small plugin, cached across sites | Third-party request in the purchase path |
Interesting Challenge:
Commerce raises the stakes on failure modes. A portfolio page with a broken viewer is disappointing; a product page with a broken viewer can block a sale. That pushed the design toward strict progressive enhancement so no model reference means no runtime request and no empty container, and a failed model load leaves the ordinary product gallery fully functional. The viewer must be provably subtractable.
KEY TAKE-AWAY
In regard to software engineering, the comparator section provides the most valuable insight in this case study. Four iterations, each failing for a different and diagnosable reason, ending at the conventional block.json architecture, shows my debugging methodology and willingness to discard a clunky, yet somewhat working, prototype for the correct structure.
GITHUB SHARE
wp-3d-toolkit — WordPress plugins for publishing interactive 3D work
github.com/bofrank/wp-3d-toolkit
A monorepo of WordPress plugins that bring GLB models into the block editor: a single-block viewer, a structured portfolio content type, and a before/after model comparator with synchronized turntable controls. Two plugins are implemented and running on a test site; two remain documented design proposals in proposals/, and the README says so plainly rather than implying five shipped products.
Architecture. Everything is built on core APIs rather than around them. The portfolio plugin registers a custom post type, a taxonomy, and typed meta through register_meta() with per-field sanitize_callbacks, so structured production data (software, render engine, polycount) stays queryable and REST-accessible while narrative content stays in the block editor. The comparator is a block.json-defined block registered once on the server, with render_callback output and a JavaScript layer that supplies only the editing interface.
Performance. The WebGL runtime is enqueued from the render callback rather than on wp_enqueue_scripts, so a page without a viewer never requests it. <model-viewer> loads as an ES module via wp_enqueue_script_module() where available, falling back to a script_loader_tag filter that injects the type attribute into the tag core generated preserving core’s id and loading strategy instead of rebuilding the tag. Every viewer degrades to a poster image without WebGL.
Security. GLB uploads are permitted only for users who can already upload files; because servers commonly sniff .glb as application/octet-stream, the extension is treated as authoritative behind that capability check. Save handlers guard on nonce, autosave, and capability in that order, and sanitize on read as well as by type. Output is escaped at the point of echo.
Engineering process. DECISIONS.md records eleven decisions with their trade-offs, including a post-mortem of three failed comparator architectures before the one that shipped and most usefully, that registering the same block name in both PHP and JavaScript is what broke earlier versions, and that a registered block name can never be renamed without invalidating saved content. Superseded versions are kept in docs/iterations/ as .txt so they cannot be installed by accident.
CI. GitHub Actions runs WordPress Coding Standards plus PHP 7.4 and 8.2 syntax checks. Getting it green meant fixing real defects which are the unsanitized $_POST reads, an unescaped attribute variable and rather than disabling sniffs; the few ruleset exclusions are scoped to specific paths and carry a comment stating why the sniff is wrong there. Where wp-scripts lint-js could not run meaningfully yet, I replaced it with a small dependency-free validator instead of leaving a badge that verified nothing.
Known gaps, documented: the comparator’s build/ is hand-authored pending a src/ migration, there is no PHPUnit coverage yet, and multisite is untested.

