From d6c0ba4c0ed1ffd0bd6b0db8ed30c503f4a4af5a Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 24 Jul 2026 19:47:04 +1000 Subject: [PATCH] NSFW tagging: make "only dressed outfits ship" an enforced rule instead of a README line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++- web/index.html | 38 +++++++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/server.py b/server.py index 38e8f9b..6f7fcff 100644 --- a/server.py +++ b/server.py @@ -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'] diff --git a/web/index.html b/web/index.html index 7d72677..cb85fed 100644 --- a/web/index.html +++ b/web/index.html @@ -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 @@

bodies

+
+ + +

body ops

@@ -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') ? `` : ''; + // 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 ? 'NSFW' : ''; return `
- ${tick}${it.name}${it.kb}kb · ${it.home}
`; + ${tick}${it.name.replace(/-nsfw/i, '')} + ${badge}${it.kb}kb · ${it.home}
`; +} +function visible(rows) { + return (rows || []).filter(r => SHOW_NSFW || !r.nsfw); } function renderLists() { - $('bodies').innerHTML = (LIB.bodies || []).map(i => rowHtml(i, 'bodies')).join('') || '
drop a GLB/FBX above
'; - $('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '
none yet — generate one →
'; - $('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '
nothing generated yet
'; - $('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '
no outfits assembled yet
'; + const nb = (LIB.bodies || []).filter(r => r.nsfw).length; + $('bodies').innerHTML = visible(LIB.bodies).map(i => rowHtml(i, 'bodies')).join('') || '
drop a GLB/FBX above
'; + if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `
${nb} anatomical base/s hidden
`); + $('garments').innerHTML = visible(LIB.garments).map(i => rowHtml(i, 'garments')).join('') || '
none yet — generate one →
'; + $('genLib').innerHTML = visible(LIB.gen).map(i => rowHtml(i, 'gen')).join('') || '
nothing generated yet
'; + $('outs').innerHTML = visible(LIB.out).map(i => rowHtml(i, 'out')).join('') || '
no outfits assembled yet
'; // NB: deliberately NOT class="item" — the generic .item handler below would clobber this one $('savedFits').innerHTML = (LIB.outfits || []).length ? (LIB.outfits || []).map(n => ``).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) {