NSFW tagging: make "only dressed outfits ship" an enforced rule instead of a README line

The bodies list had grown to 20+ entries mixing nude anatomical bases, dressed NPCs and
assembled outputs with no way to tell them apart — and the house rule "bases stay in the
library, only dressed outfits ship into games" existed as exactly one unenforced line in
README.md:45. Tagging without a gate would just be labelling; the gate is the point.

Follows the convention already in use across the fleet rather than inventing a second one:
a '-nsfw' suffix before the extension, as in ~/Documents/FBX (106 of 124 files tagged, with a
matching `nsfw` column in _INVENTORY.csv). Filenames beat a sidecar database here because these
assets get rsynced between six machines constantly — a tag in the name travels with the file,
cannot drift out of sync, and is visible in Finder.

· scan() reports `nsfw` per asset; the UI shows a badge, hides bases by default behind a "show
  bases" toggle, and says how many are hidden rather than silently shortening the list.
· POST /api/tag renames an asset to add or remove the suffix.
· Export into a live game directory now REFUSES anything nsfw-tagged (403). Verified both ways:
  a tagged stem is blocked, an untagged one still exports.
· An assembled result INHERITS the body's tag. Clearing it automatically because "a garment was
  added" would be a silent wrong call — a hat does not clothe a nude base — and the failure mode
  is shipping a nude model into a game, so untagging stays deliberate.

Verified in-browser: 1 base hidden with the count shown, badge renders, toggle reveals it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-24 19:47:04 +10:00
parent 037f6ef0af
commit d6c0ba4c0e
2 changed files with 85 additions and 6 deletions

View File

@ -75,6 +75,29 @@ def slug(s):
return re.sub(r'[^a-z0-9]+', '-', (s or '').lower()).strip('-')[:60] or 'x'
# Asset classification lives in the FILENAME, matching the convention already in use across the
# fleet (~/Documents/FBX: a '-nsfw' suffix before the extension, 106 of 124 files tagged, plus an
# `nsfw` column in _INVENTORY.csv). Filenames beat a sidecar database here because assets get
# rsynced between six machines constantly — a tag in the name travels with the file and can't
# drift out of sync, and it's visible in Finder.
NSFW_RE = re.compile(r'-nsfw(?=[.\s(]|$)', re.I)
def is_nsfw(path):
return bool(NSFW_RE.search(os.path.basename(path)))
def tagged_name(name, nsfw):
"""Add or remove the -nsfw suffix, preserving the extension."""
stem, dot, ext = name.rpartition('.')
if not dot:
stem, ext = name, ''
stem = NSFW_RE.sub('', stem).rstrip('-')
if nsfw:
stem += '-nsfw'
return stem + (dot + ext if dot else '')
def allowed(path):
p = os.path.realpath(path)
roots = list(DIRS.values()) + EXTRA_BODIES + [CACHE, OUTFITS, os.path.join(LIB, 'doll')]
@ -98,6 +121,7 @@ def scan():
p = os.path.join(dd, f)
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
'home': os.path.basename(dd),
'nsfw': is_nsfw(f),
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'})
out[key] = rows
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
@ -355,6 +379,21 @@ class H(BaseHTTPRequestHandler):
body = json.loads(raw or b'{}')
if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset
p, nsfw = body.get('path', ''), bool(body.get('nsfw'))
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library'}, 403)
d, name = os.path.split(p)
new = tagged_name(name, nsfw)
if new == name:
return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'})
tgt = os.path.join(d, new)
if os.path.exists(tgt):
return self.j({'error': 'a file with that name already exists'}, 409)
os.rename(p, tgt)
# the convert cache is keyed on the source path, so a rename orphans its entry
return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw})
if u.path == '/api/outfit': # save a dress-up: body + attachments + clip + tints
# The rigid-attach sliders used to live only in browser memory ("preview only") — every
# hat placement died on reload. This is the persistence half of that tier.
@ -505,6 +544,12 @@ class H(BaseHTTPRequestHandler):
bad = [s for s in stems if '_i_' not in s]
if bad:
return self.j({'error': 'refusing to overwrite generic slot art: ' + bad[0]}, 400)
# The house rule "bases stay in the library, only dressed outfits ship into games"
# has lived as one unenforced line in the README. This is the enforcement.
nsfw = [s for s in stems if is_nsfw(s)]
if nsfw:
return self.j({'error': f'refusing to export nsfw-tagged asset into a game '
f'directory: {nsfw[0]}'}, 403)
files = [(s, os.path.join(DOLL.DOLL_DIR, s + '.png')) for s in stems]
missing = [s for s, p in files if not os.path.isfile(p)]
if missing:
@ -558,7 +603,13 @@ class H(BaseHTTPRequestHandler):
elif op == 'assemble':
b = glb_of(a['body'], jid)
gs = [glb_of(g, jid) for g in a['garments']]
out = os.path.join(DIRS['out'], slug(a.get('name') or 'outfit') + '.glb')
# An assembled result INHERITS the body's nsfw tag. Clearing it automatically
# because "a garment was added" would be a silent wrong call — a hat does not
# clothe a nude base — and the failure mode is shipping a nude model into a
# game. Untagging stays a deliberate act once you've looked at the result.
out = os.path.join(DIRS['out'],
tagged_name(slug(a.get('name') or 'outfit') + '.glb',
is_nsfw(a['body'])))
run_blender(['assemble', b, out] + gs, jid)
elif op == 'convert':
src = a['path']

View File

@ -49,6 +49,8 @@
.sw { width:22px; height:22px; border-radius:6px; border:1px solid var(--line); cursor:pointer; padding:0 }
.sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 }
.fitchip { margin:3px 4px 0 0; font-weight:400 }
.nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px;
font-size:10px; letter-spacing:.5px; font-weight:700 }
/* 2D paper-doll stage — takes over the viewport while the doll tab is open */
#dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
background:#14120e; z-index:3 }
@ -70,6 +72,10 @@
<div class="col" id="left">
<h2>bodies</h2>
<input type="file" id="upBody" accept=".glb,.gltf,.fbx,.obj" style="margin-bottom:6px">
<div class="row" style="margin:0 0 6px">
<label style="width:auto"><input type="checkbox" id="showNsfw" style="width:auto"> show bases</label>
<button id="tagBtn" class="ghost" style="margin-left:auto">tag / untag</button>
</div>
<div id="bodies"></div>
<h2>body ops</h2>
<div class="row"><label>height m</label><input id="scaleH" value="1.72"><button id="scaleBtn" class="ghost">scale</button></div>
@ -221,6 +227,7 @@ const $ = id => document.getElementById(id);
let LIB = {}, BODY = null, GARM = null, bodyRoot = null, garmRoot = null, mixer = null, clock = new THREE.Clock();
let bones = [], attached = [], picked = new Set(); // picked = fitted garments ticked for assemble
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
let SHOW_NSFW = false; // anatomical bases hidden by default
// ---------- stage ----------
const stage = $('stage');
@ -401,14 +408,23 @@ function rowHtml(it, kind) {
const on = (kind === 'bodies' && BODY && BODY.path === it.path) || (kind !== 'bodies' && GARM && GARM.path === it.path);
const tick = kind === 'garments' && it.name.endsWith('-fitted.glb')
? `<input type="checkbox" data-pick="${it.path}" ${picked.has(it.path) ? 'checked' : ''} onclick="event.stopPropagation()">` : '';
// the -nsfw suffix is the fleet-wide convention (see ~/Documents/FBX); surfacing it here is
// what makes "bases stay in the library" visible rather than a line in a README
const badge = it.nsfw ? '<span class="nsfw" title="anatomical base — blocked from game export">NSFW</span>' : '';
return `<div class="item ${on ? 'on' : ''}" data-k="${kind}" data-p="${it.path}">
${tick}<span class="nm">${it.name}</span><span class="meta">${it.kb}kb · ${it.home}</span></div>`;
${tick}<span class="nm">${it.name.replace(/-nsfw/i, '')}</span>
<span class="meta">${badge}${it.kb}kb · ${it.home}</span></div>`;
}
function visible(rows) {
return (rows || []).filter(r => SHOW_NSFW || !r.nsfw);
}
function renderLists() {
$('bodies').innerHTML = (LIB.bodies || []).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
$('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
$('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
$('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
const nb = (LIB.bodies || []).filter(r => r.nsfw).length;
$('bodies').innerHTML = visible(LIB.bodies).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `<div class="note">${nb} anatomical base/s hidden</div>`);
$('garments').innerHTML = visible(LIB.garments).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
$('genLib').innerHTML = visible(LIB.gen).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
$('outs').innerHTML = visible(LIB.out).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
$('savedFits').innerHTML = (LIB.outfits || []).length
? (LIB.outfits || []).map(n => `<button class="ghost fitchip" data-fit="${n}">${n}</button>`).join('')
@ -437,6 +453,18 @@ function wireUpload(inputId, to) {
}
wireUpload('upBody', 'bodies'); wireUpload('upGarm', 'garments');
$('showNsfw').onchange = e => { SHOW_NSFW = e.target.checked; renderLists(); };
$('tagBtn').onclick = async () => {
const it = BODY || GARM;
if (!it) return toast('select a body or garment first');
const now = /-nsfw/i.test(it.name);
const r = await fetch('/api/tag', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: it.path, nsfw: !now }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
toast(now ? 'untagged — this can now be exported to games' : 'tagged nsfw — blocked from game export');
BODY = GARM = null; refresh();
};
// ---------- jobs ----------
function toast(t) { $('job').textContent = t; }
async function runJob(url, payload, label) {