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

3D Model
Interactive 3D Model

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 .glb is a single binary container holding mesh, materials, and textures, so it survives a WordPress Media Library upload without broken relative asset paths. Multi-file .gltf exports would require the editor to upload and preserve sibling .bin and 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() before wp_enqueue_script(). This keeps a multi-megabyte WebGL runtime off pages that don’t need it.

Trade-offs:

DecisionGainCost
Pinned CDN for the viewer runtimeSmall plugin ZIP, fast installExternal dependency; no offline install
Server-rendered dynamic blockMarkup can be changed without re-saving postsEditor cannot show the true final render
Attribute-stored URL and attachment IDFront end works even if an ID goes staleTwo 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, and show_in_rest makes 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

Base mesh Final model

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:

  1. A base state, such as a wireframe, low-poly mesh, clay study, or early design revision
  2. 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:

  1. 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 executed registerBlockType(). Fixed by enqueueing on enqueue_block_editor_assets.
  2. v0.2: script runs, getBlockType() returns undefined. 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 returned undefined, the documented signal that registerBlockType() refused the definition.
  3. v0.3: invalid render path. The block.json render property pointed at the plugin bootstrap file instead of a dedicated render template. render expects a template, not an entry point.
  4. v0.4: the correct shape. block.json as the single source of truth for name, attributes, and assets; a dedicated PHP render_callback for 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 404 in 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

$3.90

This is a 3D model of a free water vending machine found in Seoul.

Category:

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:

DecisionGainCost
Product-level metaSimple, familiar admin workflowNo per-variation models
Conditional runtime enqueueNo 3D cost on catalog or checkout pagesRequires a meta check on every product page
CDN-hosted viewer runtimeSmall plugin, cached across sitesThird-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.