Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/editor/public/items/urinal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Urinal

A generic wall-mounted ceramic urinal for layout planning. The mesh is authored
procedurally using the elliptical shell approach from `kkg-agentic-bim`, with a
recessed bowl, rounded lip and drain cap. It represents no manufacturer or product.
The generator and its outputs are available under this repository's MIT license;
there are no downloaded meshes, textures or other external assets.

## Asset contract

- Dimensions: **0.40 m wide × 0.65 m high × 0.35 m deep**.
- glTF coordinates: X across, Y up, +Z facing out from the wall.
- Bounds: X = −0.20…0.20, Y = 0…0.65, Z = 0…0.35. The origin is at the
bottom center of the back face, so `attachTo: 'wall-side'` needs no offset.
- Set mounting height with the existing wall-item placement / position controls.
Dimensions describe the fixture, not its elevation above the floor.
- `slot_ceramic` and `slot_drain` expose separate paintable materials.
- `floor-plan.png` is a transparent, tightly framed top view: back at the top,
front at the bottom. Its aspect ratio matches the 0.40 × 0.35 m footprint.
- Geometry is for visualization; it does not define supply/waste connections.

## Regenerate

From the repository root, with **Blender 4.5 LTS** installed:

```sh
blender --background --factory-startup --python scripts/generate-urinal.py
```

This writes `model.glb`, `thumbnail.png` and `floor-plan.png` into this directory.
The render lights and camera are excluded from the GLB.

## Preview locally

The catalog uses `/items/urinal/…` paths resolved through the existing asset CDN
setting. To serve the committed assets from the local editor during development:

```sh
NEXT_PUBLIC_ASSETS_CDN_URL=http://localhost:3002 bun dev
```

Open the Bathroom catalog or search for **Urinal**, select it, and place it on a
wall face. Verify both wall sides, the mounting height and the 2D floor-plan view.
Binary file added apps/editor/public/items/urinal/floor-plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/editor/public/items/urinal/model.glb
Binary file not shown.
Binary file added apps/editor/public/items/urinal/thumbnail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packages/editor/src/components/ui/item-catalog/catalog-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,20 @@ export const CATALOG_ITEMS: CatalogItem[] = [
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'urinal',
category: 'bathroom',
name: 'Urinal',
tags: ['wall', 'bathroom', 'urinal', 'plumbing', 'ceramic'],
thumbnail: '/items/urinal/thumbnail.png',
src: '/items/urinal/model.glb',
floorPlanUrl: '/items/urinal/floor-plan.png',
dimensions: [0.4, 0.65, 0.35],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
attachTo: 'wall-side',
},
{
id: 'microwave',
category: 'kitchen',
Expand Down
14 changes: 14 additions & 0 deletions packages/mcp/src/tools/asset-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,20 @@ export const MCP_CATALOG_ITEMS: AssetInput[] = [
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'urinal',
category: 'bathroom',
tags: ['wall', 'bathroom', 'urinal', 'plumbing', 'ceramic'],
name: 'Urinal',
thumbnail: '/items/urinal/thumbnail.png',
src: '/items/urinal/model.glb',
floorPlanUrl: '/items/urinal/floor-plan.png',
dimensions: [0.4, 0.65, 0.35],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
attachTo: 'wall-side',
},
{
id: 'bathroom-sink',
category: 'bathroom',
Expand Down
181 changes: 181 additions & 0 deletions scripts/generate-urinal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Generate the catalog urinal with Blender 4.5 LTS (see the asset README)."""

import math
from pathlib import Path

import bpy
from mathutils import Vector


ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "apps/editor/public/items/urinal"
OUTPUT.mkdir(parents=True, exist_ok=True)

bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 1


def material(name, color, roughness, metallic=0):
result = bpy.data.materials.new(name)
result.use_nodes = True
shader = result.node_tree.nodes.get("Principled BSDF")
shader.inputs["Base Color"].default_value = (*color, 1)
shader.inputs["Roughness"].default_value = roughness
shader.inputs["Metallic"].default_value = metallic
return result


ceramic = material("slot_ceramic", (0.82, 0.85, 0.87), 0.22)
chrome = material("slot_drain", (0.42, 0.46, 0.5), 0.24, 0.85)

# Elliptical shell sections follow the generic fixture approach in KKG's BIM
# model. Moving the inner sections downward gives the bowl a low drain sump.
# Tuples are (half width, half height, height center, front depth, rim tilt).
profile = [
(0, 0, 0.325, -0.175, 0),
(0.165, 0.295, 0.325, -0.175, 0),
(0.18, 0.32, 0.325, -0.165, 0),
(0.2, 0.325, 0.325, -0.08, -0.08),
(0.2, 0.305, 0.325, 0.05, -0.12),
(0.184, 0.282, 0.325, 0.045, -0.115),
(0.163, 0.26, 0.325, 0.015, -0.11),
(0.14, 0.19, 0.255, -0.09, -0.01),
(0.06, 0.055, 0.125, -0.04, 0),
(0, 0, 0.115, -0.045, 0),
]
segments = 48
vertices, rings, faces = [], [], []
for rx, ry, cy, depth, tilt in profile:
ring = []
for i in range(segments if rx else 1):
angle = 2 * math.pi * i / segments
ring.append(len(vertices))
# Blender Z-up / -Y-front exports to glTF Y-up / +Z-front.
vertices.append((rx * math.cos(angle), -(depth + tilt * math.sin(angle)), cy + ry * math.sin(angle)))
rings.append(ring)
for a, b in zip(rings, rings[1:]):
for i in range(segments):
j = (i + 1) % segments
if len(a) == 1:
faces.append((a[0], b[j], b[i]))
elif len(b) == 1:
faces.append((a[i], a[j], b[0]))
else:
faces.append((a[i], a[j], b[j], b[i]))

mesh = bpy.data.meshes.new("Urinal shell")
mesh.from_pydata(vertices, [], faces)
mesh.update()
body = bpy.data.objects.new("Urinal", mesh)
scene.collection.objects.link(body)
bpy.context.view_layer.objects.active = body
body.select_set(True)
body.data.materials.append(ceramic)
subdivision = body.modifiers.new("Rounded ceramic", "SUBSURF")
subdivision.levels = 2
bpy.ops.object.modifier_apply(modifier=subdivision.name)

# Normalize the authored mesh itself, so wall placement needs no corrective
# transform: back at Z=0, bottom at Y=0, front at Z=0.35 in glTF coordinates.
mins = [min(v.co[i] for v in body.data.vertices) for i in range(3)]
maxs = [max(v.co[i] for v in body.data.vertices) for i in range(3)]
sizes = (0.4, 0.35, 0.65)
for vertex in body.data.vertices:
for i in range(3):
vertex.co[i] = (vertex.co[i] - mins[i]) * sizes[i] / (maxs[i] - mins[i])
vertex.co.x -= 0.2
vertex.co.y -= 0.35
for polygon in body.data.polygons:
polygon.use_smooth = True

def project_uvs(obj):
# Project in metres rather than stretching each island to the unit square.
obj.data.update()
uv = obj.data.uv_layers.active or obj.data.uv_layers.new(name="UVMap")
for polygon in obj.data.polygons:
axis = max(range(3), key=lambda i: abs(polygon.normal[i]))
axes = ((1, 2), (0, 2), (0, 1))[axis]
for loop_index in polygon.loop_indices:
point = obj.data.vertices[obj.data.loops[loop_index].vertex_index].co
uv.data[loop_index].uv = (point[axes[0]], point[axes[1]])


project_uvs(body)

# The small drain cap sits on the lowest inner section; it is visual detail,
# not a plumbing connection or a manufacturer-specific fitting.
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=12, location=(0, -0.139, 0.112))
drain = bpy.context.object
drain.name = "Drain"
drain.scale = (0.025, 0.006, 0.022)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
drain.data.materials.append(chrome)
for polygon in drain.data.polygons:
polygon.use_smooth = True
project_uvs(drain)

bpy.ops.object.select_all(action="DESELECT")
body.select_set(True)
drain.select_set(True)
bpy.ops.export_scene.gltf(
filepath=str(OUTPUT / "model.glb"),
export_format="GLB",
use_selection=True,
export_extras=True,
export_cameras=False,
export_lights=False,
)

scene.render.engine = "CYCLES"
scene.cycles.samples = 32
scene.cycles.use_denoising = True
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.film_transparent = True
scene.render.resolution_percentage = 100
scene.world.use_nodes = True
scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.7, 0.75, 0.8, 1)
scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.4
scene.view_settings.view_transform = "AgX"


def point_at(obj, target):
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()


for name, position, energy, size in [
("Key", (1, -2, 2), 110, 1.8),
("Fill", (-1, -0.5, 1), 55, 1.4),
("Rim", (0, 1, 1.5), 80, 1),
]:
light = bpy.data.lights.new(name, "AREA")
light.energy = energy
light.shape = "DISK"
light.size = size
obj = bpy.data.objects.new(name, light)
scene.collection.objects.link(obj)
obj.location = position
point_at(obj, (0, -0.15, 0.3))

camera = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera"))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = 0.85
camera.location = (1, -1.6, 1.0)
point_at(camera, (0, -0.15, 0.325))
scene.render.resolution_x = 450
scene.render.resolution_y = 450
scene.render.filepath = str(OUTPUT / "thumbnail.png")
bpy.ops.render.render(write_still=True)

camera.location = (0, -0.175, 3)
camera.rotation_euler = (0, 0, 0)
camera.data.ortho_scale = 0.4
scene.render.resolution_x = 400
scene.render.resolution_y = 350
scene.render.filepath = str(OUTPUT / "floor-plan.png")
bpy.ops.render.render(write_still=True)
Loading